What is the best way to consume flowables from my ...
# ios
g
What is the best way to consume flowables from my shared module in ios to be used on SwiftUI views. Right now I am doing something like this -
Copy code
class AppState: ObservableObject {

    private var closeables = Array<Closeable>()
    private let viewmodel = AppViewModel()
    
    @Published private(set) var data:AppData? = nil
    
    init() {
        let close:Closeable = UtilsKt.watch(viewmodel.data) { any in. // watch is an extension function on flow that returns a closeable
            self.data = any as? AppData
        }
        
        closeables.append(close)
        
        viewmodel.load() // some api call
    }
    
    deinit {
        closeables.forEach { it in
            it.onClose()
        }
    }
    
}
Then use the object of AppState (ignore the naming) on SwiftUI view, marked with @ObservedObject. This is a bit tedious as I'll have to copy and mark (@published) for each exposed property from my shared viewmodel. Could directly use the viewmodel in a view but there is no lifecycle I know of where I can call close.
l
The way I do it: First of all I’m using
CFlow
from the KotlinConf app (https://github.com/JetBrains/kotlinconf-app/blob/master/common/src/mobileMain/kotlin/org/jetbrains/kotlinconf/FlowUtils.kt) to have a generic Flow in iOS. I also have the following utility which creates a publisher from the flow:
Copy code
func asPublisher<T>(_ flow: CFlow<T>) -> AnyPublisher<T, Never> {
    return Deferred<Publishers.HandleEvents<PassthroughSubject<T, Never>>> {
        let subject = PassthroughSubject<T, Never>()
        let closable = flow.watch { next in
            if let next = next {
                subject.send(next)
            }
        }
        return subject.handleEvents(receiveCancel: {
            closable.close()
        })
    }.eraseToAnyPublisher()
}
Now I could subscribe to that in an ObservableObject similar as you. However I created a wrapper view with a content builder in which I just pass the publisher:
Copy code
public struct ObservingView<Observed, Content>: View where Content: View {

    @State private var model: Observed? = nil

    private let publisher: AnyPublisher<Observed, Never>
    private let content: (Observed) -> Content

    public init(publisher: AnyPublisher<Observed, Never>, @ViewBuilder content: @escaping (Observed) -> Content) {
        self.publisher = publisher
        self.content = content
    }

    public var body: some View {
        let view: AnyView
        if let model = self.model {
            view = AnyView(content(model))
        } else {
            view = AnyView(EmptyView())
        }
        return view.onReceive(publisher) { self.model = $0 }
    }
}
And then I use it all as following:
Copy code
private struct ViewWithObserver: View {

    let viewModel: ViewModel // has output wich is a CFlow of ViewModelOutput

    var body: some View {
        ObservingView(publisher: asPublisher(viewModel.output))
    }
}

private struct InnerView: View {
    let output: ViewModelOutput // is a data class with all properties used in the view

    var body: some View {
        Text(output.title)
    }
}
Here the InnerView automatically gets updated by the
ObservingView
whenever something from the
ViewModelOutput
changes.
👍 1
l
This is super interesting! have you expanded on this somewhere else (eg. blog post)?
l
Not yet but I was planning to write a blog post on it. Will keep this thread up to date when I do so
👍 2
🙏 1
l
Looking forward to it! Thanks
g
@Lammert Westerhoff Thanks! I am very new to ios so looking forward to the blog post.
I have a simple question, my viewmodel output has multiple cflow properties, I do not want to club them into one data class. Would you say in that case your approach can still be useful?
l
@Gurupad Mamadapur [FH] I guess if you use those in different parts of your View you could still use them as separate flows. What’s the reason you don’t want them together in a single data class?
r
Hi! Looks like a nice article. I’ve been looking for something like
asPublisher()
and it’s neat how compact your implementation is. Here’s a couple questions I came away with if it’s helpful feedback, though maybe they get beyond your intended scope: 1. What does typical error-handling look like at the Swift level? My Kotlin instincts get uncomfortable with
eraseToAnyPublisher()
because it sounds like it would discard error information, but from what I can tell it still throws if an error occurs, so maybe it’s fine. What’s the standard best practice for such things in Combine/SwiftUI? 2. Does it make sense to combine
asPublisher()
and
ObservingView
so your View could just call something like
ObservingView(cflow: viewModel.output)
? Or do the two live in different places in a more complete app? 3. Are there any useful patterns for passing interactions back to the viewmodel?
l
great article! I also had the same #1 question that Russell has. For the #3, I wonder if we could have a
SendChannel<UiEvent>
and have the click listeners send typed events to the view model
r
@Lammert Westerhoff Do you plan to publish your article soon? I’ve been putting a little sample app together using similar patterns and would love to be able to cite it.
For the record, I’ve answered my question 2 by realizing that (at least for my use-case) I wanted to be able to perform some Combine operations on my publisher before the view observes it, meaning having those pieces separate is helpful.
l
thank you for the feedback. to answer the questions: 1. Using
eraseToAnyPublisher()
does not erase error/failing cases as the name might suggest. It just erases the concrete type. All publishers are concrete types of the
Publisher
protocol. But since protocols in Swift have associated types instead of generic types using
Publisher
is not very useful. You can’t for example do something like
Publisher<String, Never>
.
AnyPublisher
is used instead. It’s a concrete type that you can convert any other publisher to so you can use
AnyPublisher
everywhere as if you would a protocol with a generic type. It’s a bit out of scope of the article since it’s more of a Swift thing, commonly misunderstood especially by those new to Swift. More related to this article: I’m using
Never
in
AnyPublisher<T, Never>
since I prefer to already do the error handing in my shared viewmodel and then for example add things like
val errorMessage: String?
to the view model output. 2. You could indeed create
ObservingView
with a cflow directly but since it’s only one additional function
asPublisher
I like to keep them separate to avoid adding initializers for all kind of different ways of constructing publishers. And like you mentioned you will soon find situations in which you need them to be separate things. 3. there are and I’ve used a variety of different ways now. some work better than others with SwiftUI. I’m planning to write follow up articles for error handling and for 3. the interactions between the view model. And I’ve just published the post on Medium: https://lwesterhoff.medium.com/using-swiftui-with-view-model-written-in-kotlin-multiplatform-mobile-67cf7b6da551
r
nice!
Yeah I also realized after-the-fact that
eraseToAnyPublisher()
isn't really the source of my error-handling confusion. But it still seemed like SwiftUI wants a
Publisher<String, Never>
so I'm not clear on what the standard Swift-level error-handling looks like.
This was my sample if you're interested: https://github.com/russhwolf/To-Do
l
nice. seeing your example I see in the FlowAdapter that you also complete it. I guess the CFlow should also actually do that but in practice I’m never using flows that are really completed. do you have any use case for this?
r
You could use the same adapter for something like RxSwift and that lets you forward completion to an Observable there. I talk about the pattern in this blog post: https://dev.to/touchlab/working-with-kotlin-coroutines-and-rxswift-24fa
l
Thanks, I’ll have a look. The project I’ve been working on the last 2 years actually also uses RxSwift instead of Combine. In there I have functions like
asDriver
and
asSignal
which essentially do the same as
asPublisher
Copy code
public func asDriver<T>(_ flow: CFlow<T>) -> Driver<T?> {
    return asObservable(flow).asDriver(onErrorDriveWith: .empty())
}

public func asSignal<T>(_ flow: CFlow<T>) -> Signal<T?> {
    return asObservable(flow).asSignal(onErrorSignalWith: .empty())
}

public func asObservable<T>(_ flow: CFlow<T>) -> Observable<T?> {
    return Observable.create { observer in
        let closable = flow.watch(block: { object in
            observer.onNext(object)
        })
        return Disposables.create {
            closable.close()
        }
    }
}
r
yeah, looks similar. I was just trying to avoid discarding error/completion info. But depending on use-case it might not be necessary.
l
in your sample project, have you considered something like the following to keep error information?
Copy code
class PublishedFlow<T> : ObservableObject {
    @Published
    var output: Result<T, KotlinError>

    init(_ publisher: AnyPublisher<T, KotlinError>, defaultValue: T) {
        output = .success(defaultValue)

        publisher
            .map { Result.success($0) }
            .catch { Result.Publisher(.failure($0)) }
            .replaceError(with: nil)
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .assign(to: &$output)
    }
}
r
Could do that but then also need to adjust the views that consume it so they process the error. That's the piece where I'm not sure if there's any sort of SwiftUI best-practice. Otherwise I'll just throw or discard the error in a different place instead of there, and I don't know what's better to a typical iOS developer.
164 Views