Skip to content

Instantly share code, notes, and snippets.

@carlynorama
Last active September 25, 2022 15:50
Show Gist options
  • Select an option

  • Save carlynorama/7532f59283bf1bddabe9932842752896 to your computer and use it in GitHub Desktop.

Select an option

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
//
// 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
}
}
//
// 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()
}
}
//
// 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
}
}
}
}
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()
}
}
//
// 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
}
}
}
@carlynorama
Copy link
Copy Markdown
Author

carlynorama commented Sep 9, 2022

Flaws/gotchas in original code noted on by Wals, (Thank you) https://twitter.com/DonnyWals/status/1568113552652730371

@carlynorama
Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment