-
Notifications
You must be signed in to change notification settings - Fork 47
[feat]: add experimental WorkflowUI telemetry hooks #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
451e3ed
28fe9cd
c9289c2
dd69582
9b898e4
a0d88e9
21a7237
2db6b33
1db7247
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,7 +22,7 @@ import UIKit | |
import Workflow | ||
|
||
/// Drives view controllers from a root Workflow. | ||
public final class WorkflowHostingController<ScreenType, Output>: UIViewController where ScreenType: Screen { | ||
public final class WorkflowHostingController<ScreenType, Output>: WorkflowUIViewController where ScreenType: Screen { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @n8chur responding here for threading purposes:
that's a reasonable point, but i think we can revisit this if it ends up being problematic in practice.
that's true, though i'm not sure if that will pose an issue for the initial use of this information. @amorde any concerns with this?
yep, this is a first pass so i have low conviction on what exactly the 'right' or desired contract is.
possibly, though i don't really have a sense of how we could concretely leverage that tool at the moment. i thought macros had to be additive, so i'm not sure we'd be able to change any existing code in client-supplied VCs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I dove a little deeper into Swift Macros after my initial comment and I think you're right, it looks like it may not be useful here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kyleve responding here for threading purposes:
could you clarify – you think this is a risk, but not one we should change the contract of ViewControllerDescription to deal with? |
||
public typealias CustomizeEnvironment = (inout ViewEnvironment) -> Void | ||
|
||
/// Emits output events from the bound workflow. | ||
|
@@ -118,13 +118,13 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll | |
updatePreferredContentSizeIfNeeded() | ||
} | ||
|
||
public override func viewWillLayoutSubviews() { | ||
override public func viewWillLayoutSubviews() { | ||
super.viewWillLayoutSubviews() | ||
applyEnvironmentIfNeeded() | ||
} | ||
|
||
override public func viewDidLayoutSubviews() { | ||
super.viewDidLayoutSubviews() | ||
defer { super.viewDidLayoutSubviews() } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this change needed for your hooks to work? We were considering moving the frame assignment to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no i don't think this change is necessary. the previous implementation of if we're potentially going to move the frame assignment out of this method, then perhaps we should leave this proposed change since it will preserve the relative ordering with the new observation events? |
||
rootViewController.view.frame = view.bounds | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
/* | ||
* Copyright 2023 Square Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
#if canImport(UIKit) | ||
import Foundation | ||
import UIKit | ||
|
||
/// Protocol that describes an observable 'event' that may be emitted from `WorkflowUI`. | ||
@_spi(ExperimentalObservation) | ||
public protocol WorkflowUIEvent { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looking for feedback on the general structuring of the event types and related protocols There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Structure looks good to me |
||
var viewController: UIViewController { get } | ||
} | ||
|
||
// MARK: ViewController Lifecycle Events | ||
|
||
/// Event emitted from a `WorkflowUIViewController`'s `viewWillLayoutSubviews` method. | ||
@_spi(ExperimentalObservation) | ||
public struct ViewWillLayoutSubviewsEvent: WorkflowUIEvent, Equatable { | ||
public let viewController: UIViewController | ||
} | ||
|
||
/// Event emitted from a `WorkflowUIViewController`'s `viewDidLayoutSubviews` method. | ||
@_spi(ExperimentalObservation) | ||
public struct ViewDidLayoutSubviewsEvent: WorkflowUIEvent, Equatable { | ||
public let viewController: UIViewController | ||
} | ||
|
||
/// Event emitted from a `WorkflowUIViewController`'s `viewWillAppear` method. | ||
@_spi(ExperimentalObservation) | ||
public struct ViewWillAppearEvent: WorkflowUIEvent, Equatable { | ||
public let viewController: UIViewController | ||
public let animated: Bool | ||
public let isFirstAppearance: Bool | ||
} | ||
|
||
/// Event emitted from a `WorkflowUIViewController`'s `viewDidAppear` method. | ||
@_spi(ExperimentalObservation) | ||
public struct ViewDidAppearEvent: WorkflowUIEvent, Equatable { | ||
public let viewController: UIViewController | ||
public let animated: Bool | ||
public let isFirstAppearance: Bool | ||
} | ||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
/* | ||
* Copyright 2023 Square Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
#if canImport(UIKit) | ||
import Foundation | ||
|
||
/// Protocol to observe events emitted from WorkflowUI. | ||
/// **N.B. This is currently part of an experimental interface, and may have breaking changes in the future.** | ||
@_spi(ExperimentalObservation) | ||
public protocol WorkflowUIObserver { | ||
func observeEvent<E: WorkflowUIEvent>(_ event: E) | ||
} | ||
|
||
// MARK: - Global Observation | ||
|
||
@_spi(ExperimentalObservation) | ||
public enum WorkflowUIObservation { | ||
/// The shared `WorkflowUIObserver` instance to which all `WorkflowUIEvent`s will be forwarded. | ||
public static var sharedUIObserver: WorkflowUIObserver? | ||
} | ||
|
||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
/* | ||
* Copyright 2023 Square Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
#if canImport(UIKit) | ||
import Foundation | ||
import UIKit | ||
|
||
/// Ancestor type from which all ViewControllers in WorkflowUI inherit. | ||
open class WorkflowUIViewController: UIViewController { | ||
jamieQ marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/// Set to `true` once `viewDidAppear` has been called | ||
public private(set) final var hasViewAppeared: Bool = false | ||
|
||
// MARK: Event Emission | ||
|
||
/// Observation event emission point. | ||
/// - Parameter event: The event forwarded to any observers. | ||
@_spi(ExperimentalObservation) | ||
public final func sendObservationEvent<E: WorkflowUIEvent>( | ||
_ event: @autoclosure () -> E | ||
) { | ||
WorkflowUIObservation | ||
.sharedUIObserver? | ||
.observeEvent(event()) | ||
} | ||
|
||
// MARK: Lifecycle Methods | ||
|
||
override open func viewWillAppear(_ animated: Bool) { | ||
sendObservationEvent(ViewWillAppearEvent( | ||
viewController: self, | ||
animated: animated, | ||
isFirstAppearance: !hasViewAppeared | ||
)) | ||
super.viewWillAppear(animated) | ||
} | ||
|
||
override open func viewDidAppear(_ animated: Bool) { | ||
let isFirstAppearance = !hasViewAppeared | ||
if isFirstAppearance { hasViewAppeared = true } | ||
|
||
super.viewDidAppear(animated) | ||
|
||
sendObservationEvent(ViewDidAppearEvent( | ||
viewController: self, | ||
animated: animated, | ||
isFirstAppearance: isFirstAppearance | ||
)) | ||
} | ||
|
||
override open func viewWillLayoutSubviews() { | ||
sendObservationEvent( | ||
ViewWillLayoutSubviewsEvent(viewController: self) | ||
) | ||
super.viewWillLayoutSubviews() | ||
} | ||
|
||
override open func viewDidLayoutSubviews() { | ||
super.viewDidLayoutSubviews() | ||
sendObservationEvent( | ||
ViewDidLayoutSubviewsEvent(viewController: self) | ||
) | ||
} | ||
} | ||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
/* | ||
* Copyright 2023 Square Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
#if canImport(UIKit) | ||
import Combine | ||
import Workflow | ||
import XCTest | ||
|
||
@_spi(ExperimentalObservation) import WorkflowUI | ||
|
||
open class WorkflowUIObservationTestCase: XCTestCase { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there's some scratch work in here, but some ideas for how one could tie into this system and forward events through Combine |
||
var publishingObserver: PublishingObserver! | ||
|
||
var observedEvents: [WorkflowUIEvent] = [] | ||
|
||
private var cancellables: [AnyCancellable] = [] | ||
|
||
override open func invokeTest() { | ||
publishingObserver = PublishingObserver() | ||
defer { publishingObserver = nil } | ||
|
||
// collect all events emitted during test invocation | ||
publishingObserver.subject | ||
.sink { [weak self] event in | ||
self?.observedEvents.append(event) | ||
} | ||
.store(in: &cancellables) | ||
|
||
withGlobalObserver(publishingObserver) { | ||
super.invokeTest() | ||
} | ||
} | ||
|
||
private func withGlobalObserver(_ globalObserver: WorkflowUIObserver, perform: () -> Void) { | ||
let oldObserver = WorkflowUIObservation.sharedUIObserver | ||
defer { | ||
WorkflowUIObservation.sharedUIObserver = oldObserver | ||
} | ||
|
||
WorkflowUIObservation.sharedUIObserver = globalObserver | ||
perform() | ||
} | ||
|
||
func observationEvents( | ||
from viewController: WorkflowUIViewController, | ||
perform: () -> Void | ||
) -> [WorkflowUIEvent] { | ||
var events: [WorkflowUIEvent] = [] | ||
|
||
let scopedObserver = publishingObserver | ||
.publisher | ||
.filter { $0.viewController === viewController } | ||
.sink { events.append($0) } | ||
defer { scopedObserver.cancel() } | ||
|
||
perform() | ||
|
||
return events | ||
} | ||
} | ||
|
||
final class PublishingObserver: WorkflowUIObserver { | ||
let subject: PassthroughSubject<WorkflowUIEvent, Never> | ||
private(set) lazy var publisher = { subject.eraseToAnyPublisher() }() | ||
|
||
init() { | ||
self.subject = .init() | ||
} | ||
|
||
func observeEvent<E: WorkflowUIEvent>(_ event: E) { | ||
subject.send(event) | ||
} | ||
} | ||
|
||
// MARK: Event Introspection Utilities | ||
|
||
typealias EventDescriptor = String | ||
extension EventDescriptor { | ||
static var viewWillAppear: EventDescriptor = "\(ViewWillAppearEvent.self)" | ||
|
||
static var viewDidAppear: EventDescriptor = "\(ViewDidAppearEvent.self)" | ||
|
||
static var viewWillLayoutSubviews: EventDescriptor = "\(ViewWillLayoutSubviewsEvent.self)" | ||
|
||
static var viewDidLayoutSubviews: EventDescriptor = "\(ViewDidLayoutSubviewsEvent.self)" | ||
} | ||
|
||
extension WorkflowUIEvent { | ||
var descriptor: EventDescriptor { | ||
"\(type(of: self))" | ||
} | ||
} | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@square-tomb responding here for threading purposes:
good callouts, but given the current motivations for this interface, i think it's okay to punt on the story with SwiftUI and ignore this for the time being.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
being able to recover the Screen type from ScreenViewController may be of use
adding some additional events that are more specific to the logic in some of the VC subtypes may be something we want to do eventually, but for now i thought we'd just start with VC lifecycle information and see how far that gets us.