Last active
September 25, 2022 15:50
-
-
Save carlynorama/7532f59283bf1bddabe9932842752896 to your computer and use it in GitHub Desktop.
Watching an @published as a stream "for free" For a more complete exploration: https://github.com/carlynorama/AsyncPublisherTests and https://github.com/carlynorama/NotificationTasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // ComparingApproaches.swift | |
| // NotificationTasks | |
| // | |
| // Created by carlynorama on 9/15/22. | |
| // | |
| // | |
| // https://www.donnywals.com/comparing-lifecycle-management-for-async-sequences-and-publishers/ (code appraoch has been depricated since written) | |
| // https://www.hackingwithswift.com/quick-start/concurrency/how-to-create-a-custom-asyncsequence | |
| import Foundation | |
| import Combine | |
| import SwiftUI | |
| struct ComparisonContainerView: View { | |
| @State var showExampleView = false | |
| var body: some View { | |
| Button("Show example") { | |
| showExampleView = true | |
| }.sheet(isPresented: $showExampleView) { | |
| ComparisonView() | |
| } | |
| } | |
| } | |
| class ComparisonViewModel { | |
| private var tasksToCancel:[Task<(), Never>] = [] | |
| func tearDown() { | |
| for task in tasksToCancel { | |
| task.cancel() | |
| } | |
| } | |
| func notificationCenterPublisher() -> AnyPublisher<UIDeviceOrientation, Never> { | |
| NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) | |
| .map { _ in UIDevice.current.orientation } | |
| .eraseToAnyPublisher() | |
| } | |
| func notificationCenterSequence() async -> AsyncMapSequence<NotificationCenter.Notifications, UIDeviceOrientation> { | |
| await NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) | |
| .map { _ in await UIDevice.current.orientation } | |
| } | |
| var subTaskSpawingingStream:AsyncStream<UIDeviceOrientation> { | |
| return AsyncStream { continuation in | |
| let streamObserver = Task { | |
| let sequence = await notificationCenterSequence() | |
| for await orientation in sequence { | |
| print("\(orientation)") | |
| continuation.yield(orientation) | |
| } | |
| } | |
| tasksToCancel.append(streamObserver) | |
| } | |
| } | |
| var asyncStream:AsyncStream<UIDeviceOrientation> { | |
| return AsyncStream.init(unfolding: unfolding, onCancel: onCancel) | |
| //() async -> _? | |
| func unfolding() async -> UIDeviceOrientation? { | |
| let sequence = await notificationCenterSequence() | |
| for await orientation in sequence { | |
| print("\(orientation)") | |
| return orientation | |
| } | |
| return nil | |
| } | |
| //optional | |
| @Sendable func onCancel() -> Void { | |
| print("ComaprisonVM asyncStream Got Canceled") | |
| } | |
| } | |
| } | |
| struct ComparisonView: View { | |
| @State var isPortraitFromPublisher = false | |
| @State var isPortraitFromSequence = false | |
| @State var isPortraitFromLocalSequence = false | |
| let viewModel = ComparisonViewModel() | |
| var body: some View { | |
| VStack { | |
| Text("Portrait from publisher: \(isPortraitFromPublisher ? "yes" : "no")") | |
| Text("Portrait from sequence: \(isPortraitFromSequence ? "yes" : "no")") | |
| Text("Portrait from local sequence: \(isPortraitFromLocalSequence ? "yes" : "no")") | |
| } | |
| //Bespoke publisher. | |
| .onReceive(viewModel.notificationCenterPublisher()) { orientation in | |
| isPortraitFromPublisher = orientation == .portrait | |
| } | |
| //As custom async sequence. | |
| .task { await watchForFlips() } | |
| //Async stream. Requires teardown as written. | |
| .task { | |
| for await value in viewModel.subTaskSpawingingStream { | |
| isPortraitFromSequence = value == .portrait | |
| } | |
| } | |
| .onDisappear(perform: viewModel.tearDown) | |
| .task { await watchForFlips() } | |
| .task { | |
| defer { print("Async Stream Ended W/o cancel.")} | |
| for await value in viewModel.asyncStream { | |
| isPortraitFromSequence = value == .portrait | |
| } | |
| } | |
| // can of course do it all inline. | |
| // .task { | |
| // let sequence = NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) | |
| // .map { _ in await UIDevice.current.orientation } | |
| // for await orientation in sequence { | |
| // isPortraitFromLocalSequence = orientation == .portrait | |
| // print(orientation) | |
| // } | |
| // } | |
| } | |
| func watchForFlips() async { | |
| let flipWatcher = FlipWatcher() | |
| do { | |
| for try await value in flipWatcher { | |
| withAnimation { | |
| isPortraitFromLocalSequence = value == .portrait | |
| print("FlipWatcher: \(value)") | |
| } | |
| } | |
| } catch { | |
| } | |
| } | |
| } | |
| struct FlipWatcher: AsyncSequence, AsyncIteratorProtocol { | |
| typealias Element = UIDeviceOrientation | |
| private var isActive = true | |
| mutating func next() async throws -> Element? { | |
| guard isActive else { return nil } | |
| let sequence = await NotificationCenter.default.notifications(named: UIDevice.orientationDidChangeNotification) | |
| .map { _ in await UIDevice.current.orientation } | |
| for await orientation in sequence { | |
| print("\(orientation)") | |
| return orientation | |
| } | |
| return nil | |
| } | |
| func makeAsyncIterator() -> FlipWatcher { | |
| self | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // FlavorManager.swift | |
| // SimpleServiceModel | |
| // | |
| // Created by carlynorama on 9/8/22. | |
| // | |
| // AS A PROJECT REPO: https://github.com/carlynorama/AsyncPublisherTests | |
| // | |
| // https://developer.apple.com/documentation/combine/asyncpublisher | |
| // https://www.donnywals.com/comparing-use-cases-for-async-sequences-and-publishers/ | |
| // memory issues discussed: https://www.donnywals.com/comparing-lifecycle-management-for-async-sequences-and-publishers/ | |
| // https://www.youtube.com/watch?v=ePPm2ftSVqw (How to use AsyncPublisher to convert @Published to Async / Await) | |
| // https://www.hackingwithswift.com/articles/179/capture-lists-in-swift-whats-the-difference-between-weak-strong-and-unowned-references | |
| // https://www.mikeash.com/pyblog/friday-qa-2017-09-22-swift-4-weak-references.html | |
| // https://forums.swift.org/t/explicit-self-not-required-for-task-closures/54364/7 | |
| //A quick note on this approach. If you want the viewModel to do a task for the life of the view and then stop, put the Task call IN THE VIEW. | |
| //To catch this typer of thing in XCode 14 (beta build 3+) | |
| //Setting Build Setting > Strict Concurrency Checking | |
| import SwiftUI | |
| struct CasualFlavorsView: View { | |
| @StateObject private var viewModel = CasualFlavorVM() | |
| var body: some View { | |
| VStack { | |
| Text(viewModel.thisWeeksSpecial) | |
| ScrollView { | |
| VStack { | |
| ForEach(viewModel.flavorsToDisplay) { | |
| Text($0.name) | |
| } | |
| } | |
| } | |
| } | |
| //This is actually the way, if the view model | |
| //should evaporate with the view. Put the Tasks | |
| //in the view and in the view only. | |
| //Each must be its own seperate task to run concurently. | |
| .task { | |
| await viewModel.start() | |
| } | |
| .task { | |
| await viewModel.listenForFlavorOfTheWeek() | |
| } | |
| //If the tasks are going to live in the view model, | |
| //they must be torn down if they are meant | |
| //to go away with the view. | |
| //If they are meant to go to completion, it doesn't matter. | |
| .onDisappear(perform: viewModel.tearDown) | |
| } | |
| // NOTE The below pattern IS NOT THE SAME. | |
| // The task persists after view dismissal, viewModel DOES NOT deinit. | |
| // | |
| // VStack{...}.onAppear(perform:test) | |
| // | |
| // func test() { | |
| // Task { | |
| // await viewModel.listenForFlavorOfTheWeek() | |
| // } | |
| // } | |
| } | |
| struct CasualFlavorsView_Previews: PreviewProvider { | |
| static var previews: some View { | |
| CasualFlavorsView() | |
| } | |
| } | |
| //Listener architecture to p | |
| class CasualFlavorVM:ObservableObject { | |
| @MainActor @Published var flavorsToDisplay: [Flavor] = [] | |
| @MainActor @Published var thisWeeksSpecial:String = "" | |
| let manager = FlavorManager() | |
| var listener:Task<(),Never>? | |
| public func tearDown() { | |
| listener?.cancel() | |
| } | |
| init() { | |
| print("hello") | |
| listen() | |
| } | |
| deinit { | |
| print("goodbye") | |
| } | |
| //Who owns tasks called here? Who kills them? | |
| private func listen() { | |
| listener = Task { | |
| await listenForFlavorList() | |
| } | |
| } | |
| public func listenForFlavorOfTheWeek() async { | |
| for await value in await manager.$currentFlavor.values { | |
| await MainActor.run { //[weak self] in | |
| self.thisWeeksSpecial = "\(value.name): \(value.description)" | |
| } | |
| } | |
| } | |
| public func listenForFlavorList() async { | |
| for await value in await manager.$myFlavors.values { | |
| await MainActor.run { //[weak self] in | |
| self.flavorsToDisplay = value | |
| } | |
| } | |
| } | |
| func start() async { | |
| await manager.addData() | |
| } | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // FlavorManager.swift | |
| // SimpleServiceModel | |
| // | |
| // Created by carlynorama on 9/8/22. | |
| // Alternative Approach where tasks run in background. | |
| struct InsistantFlavorsView: View { | |
| //there is a task creator IN THE INIT of this VM. The tasks will last with the VM or longer. Watch for leaks. | |
| @EnvironmentObject private var viewModel:InsistantFlavorVM | |
| var body: some View { | |
| VStack { | |
| Text(viewModel.thisWeeksSpecial) | |
| ScrollView { | |
| VStack { | |
| ForEach(viewModel.flavorsToDisplay) { | |
| Text($0.name) | |
| } | |
| } | |
| } | |
| } | |
| //Each must be its own seperate task to run concurently. | |
| .task { | |
| //should be cleaned up and not leak. | |
| await viewModel.listenForFlavorOfTheWeek() | |
| } | |
| } | |
| } | |
| struct InsistantFlavorsView_Previews: PreviewProvider { | |
| static var previews: some View { | |
| InsistantFlavorsView().environmentObject(InsistantFlavorVM()) | |
| } | |
| } | |
| //This VM must instiated at the ROOT level of the app, once | |
| //and ONLY once or you'll continue to spawn tasks. | |
| class InsistantFlavorVM:ObservableObject { | |
| @MainActor @Published var flavorsToDisplay: [Flavor] = [] | |
| @MainActor @Published var thisWeeksSpecial:String = "" | |
| //I believe this will keep this instance alive? | |
| // can this be weak? it needs to also have a task killer? | |
| let manager = FlavorManager() | |
| @MainActor @Published var showMe:Bool = false | |
| @MainActor @Published var acceptingAlerts = false | |
| init() { | |
| print("background hello") | |
| //spinning up tasks in the init of a ViewModel instead of the | |
| //view means they will likely persist for longer than the view. | |
| //inside the listen function set the instance variable instead. | |
| listen() | |
| } | |
| deinit { | |
| print("never say goodbye...") | |
| } | |
| //Who owns tasks called here? Who kills them? | |
| private func listen() { | |
| //One cannot put one loop after another. Each loop needs | |
| //it's own task. | |
| //Use this pattern if you want the task to have to complete. | |
| Task { await manager.slowAddData() } | |
| Task { [weak self] in | |
| await self?.listenForFlavorList() | |
| //No code here will execute because this function never | |
| //finishes. | |
| } | |
| } | |
| public func listenForFlavorOfTheWeek() async { | |
| for await value in await manager.$currentFlavor.values { | |
| await MainActor.run { //[weak self] in | |
| self.thisWeeksSpecial = "\(value.name): \(value.description)" | |
| } | |
| } | |
| } | |
| public func listenForFlavorList() async { | |
| for await value in await manager.$myFlavors.values { | |
| await MainActor.run { //[weak self] in | |
| if self.acceptingAlerts { | |
| self.showMe = true | |
| } | |
| self.flavorsToDisplay = value | |
| } | |
| } | |
| } | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| struct ContentView: View { | |
| //For self destructing view | |
| @State private var showingPopover = false | |
| //For Persisting view | |
| //This VM must instiated at the ROOT level of the app, once | |
| //and ONLY once or you'll continue to spawn tasks. | |
| @StateObject private var insistant = InsistantFlavorVM() | |
| var body: some View { | |
| VStack { | |
| Button("Show & Update While Looking") { | |
| showingPopover = true | |
| } | |
| .popover(isPresented: $showingPopover) { | |
| CasualFlavorsView() | |
| } | |
| //7) Shouls pop back up with every new flavor. | |
| Button("Drive Background Alerts") { | |
| insistant.acceptingAlerts = true | |
| insistant.showMe = true | |
| } | |
| .popover(isPresented: $insistant.showMe) { | |
| InsistantFlavorsView().environmentObject(insistant) | |
| } | |
| } | |
| } | |
| } | |
| struct ContentView_Previews: PreviewProvider { | |
| static var previews: some View { | |
| ContentView() | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // FlavorModel.swift | |
| // AsyncPublisherTests | |
| // | |
| // Created by Labtanza on 9/9/22. | |
| // | |
| import Foundation | |
| struct Flavor:Identifiable { | |
| let name:String | |
| let id = UUID() | |
| let description:String | |
| } | |
| let flavors = [ | |
| Flavor(name: "Vanilla", description: "Yummy"), | |
| Flavor(name: "Strawberry", description: "Yummy"), | |
| Flavor(name: "Chocolate", description: "Yummy"), | |
| Flavor(name: "Butter Pecan", description: "Yummy"), | |
| Flavor(name: "Mint Chocolate Chip", description: "Yummy"), | |
| Flavor(name: "Orange Sherbert", description: "Yummy"), | |
| Flavor(name: "Rocky Road", description: "Yummy"), | |
| Flavor(name: "Lemon Sorbet", description: "Yummy"), | |
| Flavor(name: "Cookie Dough", description: "Yummy"), | |
| Flavor(name: "Fudge Ripple", description: "Yummy"), | |
| ] | |
| actor FlavorManager { | |
| @Published var myFlavors:[Flavor] = [] | |
| @Published var currentFlavor:Flavor = Flavor(name: "Apple Pie", description: "Seasonal Yummy") | |
| func addData() async { | |
| for flavor in flavors { | |
| myFlavors.append(flavor) | |
| try? await Task.sleep(nanoseconds: 2_000_000_000) | |
| currentFlavor = flavor | |
| } | |
| } | |
| func slowAddData() async { | |
| for flavor in flavors { | |
| myFlavors.append(flavor) | |
| try? await Task.sleep(nanoseconds: 4_000_000_000) | |
| currentFlavor = flavor | |
| } | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Flaws/gotchas in original code noted on by Wals, (Thank you) https://twitter.com/DonnyWals/status/1568113552652730371