SwiftUI Why Can't pass a publisher between Views? - swift

I just want to do some test like this ↓
Create one publisher from first view
Pass it to second view
Bind the publisher with some property in second view and try to show it on screen
The code is ↓ (First View)
struct ContentView: View {
let publisher = URLSession(configuration: URLSessionConfiguration.default)
.dataTaskPublisher(for: URLRequest(url: URL(string: "https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8")!))
.map(\.data.description)
.replaceError(with: "Error!")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
var body: some View {
NavigationView {
List {
NavigationLink(destination: ResponseView(publisher: publisher)) {
Text("Hello, World!")
}
}.navigationBarTitle("Title", displayMode: .inline)
}
}
}
(Second View)
struct ResponseView: View {
let publisher: AnyPublisher<String, Never>
#State var content: String = ""
var body: some View {
HStack {
VStack {
Text(content)
.font(.system(size: 12))
.onAppear { _ = self.publisher.assign(to: \.content, on: self) }
Spacer()
}
Spacer()
}
}
}
But the code is not working. The request failed with message blow ↓
2020-11-11 11:08:04.657375+0800 PandaServiceDemo[83721:1275181] Task <6B53516E-5127-4C5E-AD5F-893F1AEE77E8>.<1> finished with error [-999] Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8, NSLocalizedDescription=cancelled, NSErrorFailingURLKey=https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8}
Can someone tell me what happened and what is the right approach to do this?

The issue here is, the Subscription isn't stored anywhere. You have to store it in a AnyCancellable var and retain the subscription.
Use .print() operator whenever you are debugging combine related issues. I find it really useful.
The right approach is to extract the publisher and subscription into an ObservableObject and inject it into the View or use #StateObject
class DataProvider: ObservableObject {
#Published var content: String = ""
private var bag = Set<AnyCancellable>()
private let publisher: AnyPublisher<String, Never>
init() {
publisher = URLSession(configuration: URLSessionConfiguration.default)
.dataTaskPublisher(for: URLRequest(url: URL(string: "https://v.juhe.cn/joke/content/list.php?sort=asc&page=&pagesize=&time=1418816972&key=aa73ebdd8672a2b9adc9dbb2923184c8")!))
.map(\.data.description)
.print()
.replaceError(with: "Error!")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func loadData() {
publisher.assign(to: \.content, on: self).store(in: &bag)
}
}
struct ContentView: View {
#StateObject var dataProvider = DataProvider()
var body: some View {
NavigationView {
List {
NavigationLink(destination: ResponseView(dataProvider: dataProvider)) {
Text("Hello, World!")
}
}.navigationBarTitle("Title", displayMode: .inline)
}
}
}
struct ResponseView: View {
let dataProvider: DataProvider
var body: some View {
HStack {
VStack {
Text(dataProvider.content)
.font(.system(size: 12))
.onAppear {
self.dataProvider.loadData()
}
Spacer()
}
Spacer()
}
}
}
Please note that we have used #StateObject to make sure that DataProvider instance does not get destroyed when the view updates.

You need to store the subscription, otherwise it would be de-initialized and automatically cancelled.
Typically, this is done like this:
var cancellables: Set<AnyCancellable> = []
// ...
publisher
.sink {...}
.store(in: &cancellables)
So, you can create a #State property like the above, or you can use .onReceive:
let publisher: AnyPublisher<String, Never>
var body: some View {
HStack {
// ...
}
.onReceive(publisher) {
content = $0
}
}
You should be careful with the above approaches, since if ResponseView is ever re-initialized, it would get a copy of the publisher (most publishers are value-types), so it would start a new request.
To avoid that, add .share() to the publisher:
let publisher = URLSession(configuration: URLSessionConfiguration.default)
.dataTaskPublisher(...)
//...
.share()
.eraseToAnyPublisher()

In terms of SwiftUI, you are doing something fundamentally wrong: creating the publisher from the View. This means a new publisher will be created every time ContentView is instantiated, and for all means and purposes this can happen a lot of times, SwiftUI makes no guarantees a View will be instantiated only once.
What you need to do is to extract the published into some object, which is either injected from upstream, or managed by SwiftUI, via #StateObject.

Well there is 2 way for this Job: way one is better
Way 1:
import SwiftUI
struct ContentView: View {
#State var urlForPublicsh: URL?
var body: some View {
VStack
{
Text(urlForPublicsh?.absoluteString ?? "nil")
.padding()
Button("Change the Publisher") {urlForPublicsh = URL(string: "https://stackoverflow.com")}
.padding()
SecondView(urlForPublicsh: $urlForPublicsh)
}
.onAppear()
{
urlForPublicsh = URL(string: "https://www.apple.com")
}
}
}
struct SecondView: View {
#Binding var urlForPublicsh: URL?
var body: some View {
Text(urlForPublicsh?.absoluteString ?? "nil")
.padding()
}
}
Way 2:
import SwiftUI
class UrlForPublicshModel: ObservableObject
{
static let shared = UrlForPublicshModel()
#Published var url: URL?
}
struct ContentView: View {
#StateObject var urlForPublicshModel = UrlForPublicshModel.shared
var body: some View {
VStack
{
Text(urlForPublicshModel.url?.absoluteString ?? "nil")
.padding()
Button("Change the Publisher") {urlForPublicshModel.url = URL(string: "https://stackoverflow.com")}
.padding()
SecondView()
}
.onAppear()
{
urlForPublicshModel.url = URL(string: "https://www.apple.com")
}
}
}
struct SecondView: View {
#ObservedObject var urlForPublicshModel = UrlForPublicshModel.shared
var body: some View {
Text(urlForPublicshModel.url?.absoluteString ?? "nil")
.padding()
}
}

Related

How do I instantly load core data in a view after closing my popup?

The point of this app is to use core data to permanently add types of fruit to a list. I have two views: ContentView and SecondScreen. SecondScreen is a pop-up sheet. When I input a fruit and press 'save' in SecondScreen, I want to immediately update the list in ContentView to reflect the type of fruit that has just been added to core data as well as the other fruits which have previously been added to core data. My problem is that when I hit the 'save' button in SecondScreen, the new fruit is not immediately added to the list in ContentView. Instead, I have to restart the app to see the new fruit in the list.
Here is the class for my core data:
class CoreDataViewModel: ObservableObject {
let container: NSPersistentContainer
#Published var savedEntities: [FruitEntity] = []
init() {
container = NSPersistentContainer(name: "FruitsContainer")
container.loadPersistentStores { (description, error) in
if let error = error {
print("Error with coreData. \(error)")
}
}
fetchFruits()
}
func fetchFruits() {
let request = NSFetchRequest<FruitEntity>(entityName: "FruitEntity")
do {
savedEntities = try container.viewContext.fetch(request)
} catch let error {
print("Error fetching. \(error)")
}
}
func addFruit(text: String) {
let newFruit = FruitEntity(context: container.viewContext)
newFruit.name = text
saveData()
}
func saveData() {
do {
try container.viewContext.save()
fetchFruits()
} catch let error {
print("Error saving. \(error)")
}
}
}
Here is my ContentView struct:
struct ContentView: View {
//sheet variable
#State var showSheet: Bool = false
#StateObject var vm = CoreDataViewModel()
#State var refresh: Bool
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button(action: {
showSheet.toggle()
}, label: {
Text("Add Fruit")
})
List {
ForEach(vm.savedEntities) { entity in
Text(entity.name ?? "NO NAME")
}
}
}
.navigationTitle("Fruits")
.sheet(isPresented: $showSheet, content: {
SecondScreen(refresh: $refresh)
})
}
}
}
Here is my SecondScreen struct:
struct SecondScreen: View {
#Binding var refresh: Bool
#Environment(\.presentationMode) var presentationMode
#StateObject var vm = CoreDataViewModel()
#State var textFieldText: String = ""
var body: some View {
TextField("Add fruit here...", text: $textFieldText)
.font(.headline)
.padding(.horizontal)
Button(action: {
guard !textFieldText.isEmpty else { return }
vm.addFruit(text: textFieldText)
textFieldText = ""
presentationMode.wrappedValue.dismiss()
refresh.toggle()
}, label: {
Text("Save")
})
}
}
To try and solve this issue, I've created a #State boolean variable called 'refresh' in ContentView and bound it with the 'refresh' variable in SecondScreen. This variable is toggled when the user hits the 'save' button on SecondScreen, and I was thinking that maybe this would change the #State variable in ContentView and trigger ContentView to reload, but it doesn't work.
In your second screen , change
#StateObject var vm = CoreDataViewModel()
to
#ObservedObject var vm: CoreDataViewModel
then provide for the instances that compiler will ask for
hope it helps
You need to use #FetchRequest instead of #StateObject and NSFetchRequest. #FetchRequest will call body to update the Views when the fetch result changes.

SwiftUI onReceive don't work with UIPasteboard publisher

I would like to subscribe to UIPasteboard changes in SwiftUI with onReceive.
pHasStringsPublisher will not be updated as soon as something in the clipboard changes and I don't understand why.
import SwiftUI
struct ContentView: View {
let pasteboard = UIPasteboard.general
#State var pString: String = "pString"
#State var pHasStrings: Bool = false
#State var pHasStringsPublisher: Bool = false
var body: some View {
VStack{
Spacer()
Text("b: '\(self.pString)'")
.font(.headline)
Text("b: '\(self.pHasStrings.description)'")
.font(.headline)
Text("p: '\(self.pHasStringsPublisher.description)'")
.font(.headline)
Spacer()
Button(action: {
self.pString = self.pasteboard.string ?? "nil"
self.pHasStrings = self.pasteboard.hasStrings
}, label: {
Text("read pb")
.font(.largeTitle)
})
Button(action: {
self.pasteboard.items = []
}, label: {
Text("clear pb")
.font(.largeTitle)
})
Button(action: {
self.pasteboard.string = Date().description
}, label: {
Text("set pb")
.font(.largeTitle)
})
}
.onReceive(self.pasteboard
.publisher(for: \.hasStrings)
.print()
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
, perform:
{ hasStrings in
print("pasteboard publisher")
self.pHasStringsPublisher = hasStrings
})
}
}
As far as I know, none of UIPasteboard's properties are documented to support Key-Value Observing (KVO), so publisher(for: \.hasStrings) may not ever publish anything.
Instead, you can listen for UIPasteboard.changedNotification from the default NotificationCenter. But if you are expecting the user to copy in a string from another application, that is still not sufficient, because a pasteboard doesn't post changedNotification if its content was changed while your app was in the background. So you also need to listen for UIApplication.didBecomeActiveNotification.
Let's wrap it all up in an extension on UIPasteboard:
extension UIPasteboard {
var hasStringsPublisher: AnyPublisher<Bool, Never> {
return Just(hasStrings)
.merge(
with: NotificationCenter.default
.publisher(for: UIPasteboard.changedNotification, object: self)
.map { _ in self.hasStrings })
.merge(
with: NotificationCenter.default
.publisher(for: UIApplication.didBecomeActiveNotification, object: nil)
.map { _ in self.hasStrings })
.eraseToAnyPublisher()
}
}
And use it like this:
var body: some View {
VStack {
blah blah blah
}
.onReceive(UIPasteboard.general.hasStringsPublisher) { hasStrings = $0 }
}

image is not loading on swiftui collectionview

I'm trying to load an image but the image is not loading.
I setup my image loader
class ImageLoader: ObservableObject {
#Published var image: UIImage?
private let url: URL
private var cancellable: AnyCancellable?
init(url: URL) {
self.url = url
}
deinit {
cancellable?.cancel()
}
func load() {
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.map { UIImage(data: $0.data) }
.replaceError(with: nil)
.receive(on: DispatchQueue.main)
.assign(to: \.image, on: self)
}
func cancel() {
cancellable?.cancel()
}
}
and then AsyncImage struct
struct AsyncImage<Placeholder: View>: View {
#ObservedObject private var loader: ImageLoader
private let placeholder: Placeholder?
init(url: URL, placeholder: Placeholder? = nil) {
loader = ImageLoader(url: url)
self.placeholder = placeholder
}
var body: some View {
image
.onAppear(perform: loader.load)
.onDisappear(perform: loader.cancel)
}
private var image: some View {
placeholder
}
}
struct ProductSearhView: View {
#ObservedObject var model: SearchResultViewModel
var body: some View{
NavigationView {
List {
ForEach(0 ..< Global.productArry.count) { value in
CollectionView(model: self.model, data: Global.productArry[value])
}
}.navigationBarTitle("CollectionView")
}
}
}
struct CollectionView: View {
#ObservedObject var model: SearchResultViewModel
let data: Product
var body: some View {
VStack {
HStack {
Spacer()
AsyncImage(url: URL(string: self.data.productImageUrl)!, placeholder: Text("Loading ...")
).aspectRatio(contentMode: .fit)
Spacer()
}
HStack {
Spacer()
Text(self.data.name)
Spacer()
}
}.onAppear(perform:thisVal)
}
func thisVal (){
print(self.data.productImageUrl)
// https://i5.walmartimages.com/asr/113d660f-8b57-4ab8-8cfa-a94f11b121aa_1.f6bad4b281983e164dcf0a160571e886.jpeg?odnHeight=180&odnWidth=180&odnBg=ffffff
}
}
Everything else works fine except the image loading. The image only shows the placeholder and not the image.
Sample image url to load is this
https://i5.walmartimages.com/asr/113d660f-8b57-4ab8-8cfa-a94f11b121aa_1.f6bad4b281983e164dcf0a160571e886.jpeg?odnHeight=180&odnWidth=180&odnBg=ffffff
What am I doing wrong?
It looks like you're missing an Image.
Where you have:
private var image: some View {
placeholder
}
You're only ever going to have a placeholder.
If you change it to be:
private var image: some View {
Group {
if loader.image != nil {
Image(uiImage: loader.image!)
.resizable()
} else {
placeholder
}
}
}
That should mean that if the ImageLoader has successfully loaded the image it'll show the Image with the loaded uiImage, otherwise it'll show the placeholder.

Updating State from ObservableObject publisher

I was experimenting with Combine and SwiftUI but stuck in updating state basically I want to update my state in the view every time ObservableObject changes,
here's the example.
class XViewModel: ObservableObject {
#Published var tVal: Bool = false
private var cancellables = Set<AnyCancellable>()
func change() {
Just(true)
.delay(for: 3.0, scheduler: RunLoop.main)
.receive(on: RunLoop.main)
.assign(to: \.tVal, on: self)
.store(in: &self.cancellables)
}
}
I have viewModel and one publisher and delayed publisher which triggers after 3 seconds.
struct ContentView: View {
#ObservedObject var viewModel = XViewModel()
#State var toggleVal: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
self.viewModel.$tVal
.sink { newVal in
print(newVal)
}
.store(in: &cancellables)
self.viewModel
.$tVal.assign(to: \.toggleVal, on: self)
.store(in: &cancellables)
viewModel.change()
}
var body: some View {
VStack {
Toggle(isOn: self.$viewModel.tVal) {
Text("Toggle")
Toggle(isOn: self.$toggleVal) {
Text("Toggle from View")
}
}
}
What I expected is
viewModel.Just triggers
viewModel.tVal publisher triggers
view.toggleVal state triggers
updates UI
But it seems although everything is updated it doesn't update state. Is there any way to update State or it wasn't meant to be updated at all and I need to bind my views directly to the viewModel's tVal value which is publisher.
Thanks.
The #State is not ready in init to operate, use instead .onReceive as below.
struct ContentView: View {
#ObservedObject var viewModel = XViewModel()
#State var toggleVal: Bool = false
private var cancellables = Set<AnyCancellable>()
init() {
self.viewModel.$tVal
.sink { newVal in
print(newVal)
}
.store(in: &cancellables)
viewModel.change()
}
var body: some View {
VStack {
Toggle(isOn: self.$viewModel.tVal) {
Text("Toggle")
Toggle(isOn: self.$toggleVal) {
Text("Toggle from View")
}
.onReceive(self.viewModel.$tVal) { newVal in // << here !!
self.toggleVal = newVal
}
}
}

How can I update view state in response to external changes?

Imagine I have a view with some mutable state, but that the state might need to be updated to reflect changes in another object (e.g. a ViewModel).
How can I implement that in SwiftUI?
I've tried the following, but can't get the view to reflect updates coming from the ViewModel:
class ViewModel: ObservableObject {
#Published var text: String = "loading"
private var task: AnyCancellable?
func fetch() {
task = Just("done")
.delay(for: 1, scheduler: RunLoop.main)
.assign(to: \.text, on: self)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var viewText = "idle"
private var bind: AnyCancellable?
init() {
viewText = viewModel.text
bind = viewModel
.$text
.print()
.assign(to: \.viewText, on: self)
}
var body: some View {
VStack {
TextField(titleKey: "editable text", text: $viewText)
Text(viewText)
Text(viewModel.text)
}
.onAppear {
self.viewModel.fetch()
}
}
}
The TextField and the first Text element get their content from ContentView.viewText, the second Text goes directly to the source: ViewModel.text.
As expected, the second Text shows "loading" and then "done". The first Text never changes from "idle".
If next screen recording looks like answering your question
it was recorded using next code
import SwiftUI
import Combine
class ViewModel: ObservableObject {
#Published var text: String = "loading"
private var task: AnyCancellable?
func fetch() {
task = Just("done")
.delay(for: 3, scheduler: RunLoop.main)
.assign(to: \.text, on: self)
}
}
struct ContentView: View {
#ObservedObject var viewModel = ViewModel()
#State var viewText = "idle"
var body: some View {
VStack {
Text(viewText)
Text(viewModel.text)
}.onReceive(viewModel.$text.filter({ (s) -> Bool in
s == "done"
})) { (txt) in
self.viewText = txt
}.onAppear {
self.viewModel.fetch()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here is possible approach (tested & works with Xcode 11.2 / iOS 13.2) - modified only ContentView:
struct ContentView: View {
#ObservedObject var viewModel = ViewModelX()
#State private var viewText = "idle"
init() {
_viewText = State<String>(initialValue: viewModel.text)
}
var body: some View {
VStack {
Text(viewText)
Text(viewModel.text)
}
.onReceive(viewModel.$text) { value in
self.viewText = value
}
.onAppear {
self.viewModel.fetch()
}
}
}