.onReceive firing twice | SwiftUI - ios

I have a SwiftUI view that includes a Picker. I'm using a Switch statement inside .onReceive of the Picker to call a function. The function calls an external API.
The problem is that the function is being called twice whenever the view is initialised i.e duplicating the data. I'm can't figure out why .onReceive is being called twice.
I think it might have something to do with the func being called when I init the Picker Model and then getting another notification from the Picker itself but I'm not sure how to work around it.
Here's my code:
Picker Model
import Foundation
class PickerModel: ObservableObject {
#Published var filter = 0
let pickerOptions = ["Popular", "Top Rated"]
}
View containing the Picker:
import SwiftUI
struct FilteredMoviesGridView: View {
#ObservedObject private var filteredMovieVM = FilteredMovieGridViewModel()
#ObservedObject private var pickerModel = PickerModel()
private var twoColumnGrid = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
NavigationView {
VStack {
Picker(selection: $pickerModel.filter, label: Text("Select")) {
ForEach(0 ..< pickerModel.pickerOptions.count) {
Text(pickerModel.pickerOptions[$0])
}
}.onReceive(pickerModel.$filter) { (value) in
switch value {
case 0:
filteredMovieVM.movies.removeAll()
filteredMovieVM.currentPage = 1
filteredMovieVM.fetchMovies(filter: "popularity")
case 1:
filteredMovieVM.movies.removeAll()
filteredMovieVM.currentPage = 1
filteredMovieVM.fetchMovies(filter: "vote_average")
default:
filteredMovieVM.movies.removeAll()
filteredMovieVM.currentPage = 1
filteredMovieVM.fetchMovies(filter: "popularity")
}
}.pickerStyle(SegmentedPickerStyle())
ScrollView {
LazyVGrid(columns: twoColumnGrid, spacing: 10) {
ForEach(filteredMovieVM.movies, id:\.id) { movie in
NavigationLink(destination: MovieDetailView(movie: movie)) {
MovieGridItemView(movies: movie)
}.buttonStyle(PlainButtonStyle())
.onAppear(perform: {
if movie == self.filteredMovieVM.movies.last {
switch pickerModel.filter {
case 0:
self.filteredMovieVM.checkTotalMovies(filter: "popularity")
case 1:
self.filteredMovieVM.checkTotalMovies(filter: "vote_average")
default:
self.filteredMovieVM.checkTotalMovies(filter: "popularity")
}
}
})
}
}
}
.navigationBarTitle("Movies")
}
}.accentColor(.white)
}
}
The View Model containing the function:
import Foundation
class FilteredMovieGridViewModel: ObservableObject {
#Published var movies = [Movie]()
private var filteredMovies = [MovieList]()
var currentPage = 1
func checkTotalMovies(filter: String) {
if filteredMovies.count < 20 {
fetchMovies(filter: filter)
}
}
func fetchMovies(filter: String) {
WebService().getMoviesByFilter(filter: filter, page: currentPage) { movie in
if let movie = movie {
self.filteredMovies.append(movie)
for movie in movie.movies {
self.movies.append(movie)
print(self.movies.count)
}
}
}
if let totalPages = filteredMovies.first?.totalPages {
if currentPage <= totalPages {
currentPage += 1
}
}
}
}
Any help would be greatly appreciated.

Most likely you're recreating your ObservedObjects whenever your FilteredMoviesGridView is recreated. This can happen whenever SwiftUI's runtime thinks it needs to recreate it. So your view creation should be cheap and you should make sure not to accidentally recreate resources you need. Luckily SwiftUI in iOS 14, etc. has made it much easier to fix this problem. Instead of using #ObservedObject, use #StateObject, which will keep the same instance alive as your view is recreated.

Related

Async update in MVVM and animation on state change

I trying to understand how Combine and SwiftUI works in combination with MVVM and clean architecture, but I encountered a problem with using withAnimation once my view model has an async method that updated published value. I was able to solve it, but I'm pretty sure it's not the correct way and I'm missing something fundamental. Here it is how it looks my solution, starting with my data manager:
protocol NameManaging {
var publisher: AnyPublisher<[Name], Never> { get }
func fetchNames() async
}
class MockNameManager: NameManaging {
var publisher: AnyPublisher<[Name], Never> {
names.eraseToAnyPublisher()
}
func fetchNames() async {
var values = await heavyAsyncTask()
names.value.append(contentsOf: values)
}
private func heavyAsyncTask() async -> [Name] {
// do some heavy async task
}
private var names = CurrentValueSubject<[Name], Never>([])
}
Then view models:
class NameListViewModel: ObservableObject {
#Published var names = [Name]()
private var anyCancellable: AnyCancellable?
private var nameManager: NameManaging
init(nameManager: NameManaging = MockNameManager()) {
self.nameManager = nameManager
self.anyCancellable = nameManager.publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] values in
withAnimation {
self?.names = values
}
})
}
func fetchNames() async {
await nameManager.fetchNames()
}
}
Lastly my view:
struct NameList: View {
#StateObject private var nameListViewModel = NameListViewModel()
var body: some View {
VStack {
VStack {
HStack {
Button(action: updateNames) {
Text("Fetch some more names")
}
}
}
.padding()
List {
ForEach(nameListViewModel.names) {
NameRow(name: $0)
}
}
}
.navigationTitle("Names list")
.onAppear(perform: updateNames)
}
func updateNames() {
Task {
await nameListViewModel.fetchNames()
}
}
}
What I did is use withAnimation inside my view model in .sink() method of data manager publisher. This works as expected, but it indroduce view function inside view model. How can I do it in a way that inside updateNames in my view I'll use withAnimation? Or maybe I should do it in completely different way?
You are mixing up technologies. The point of async/await is to remove the need for a state object (i.e. a reference type) and Combine to do async work. You can simply use the .task modifier to call any async func and set the result on an #State var. If the async func throws then you might catch the exception and set a message on another #State var. The great thing about .task is it's called when the UIView (that SwiftUI creates for you) appears and cancelled when it disappears (also if the optional id param changes). So no need for an object, which often is the cause of consistency/memory problems which Swift and SwiftUI's use of value types is designed to eliminate.
struct NameList: View {
#State var var names: [Name] = []
#State var fetchCount = 0
var body: some View {
VStack {
VStack {
HStack {
Button("Fetch some more names") {
fetchCount += 1
}
}
}
.padding()
List {
ForEach(names) { name in
NameRow(name: name)
}
}
}
.navigationTitle("Names list")
.task(id: fetchCount) {
let names = await Name.fetchNames()
withAnimation {
self.names = names
}
}
}
}

Updating swiftui text view by a function in another file

I have a function that goes out to an API and gets a stock price. When I try to run the function updateView(), I just get 50 instead of 60 which is expected in the text view. The function updateView() can't change the text. Can someone tell me what I'm doing wrong? My code is below.
struct ContentView: View {
#State var stockPrice: Double = 0.0
var body: some View {
List {
HStack {
Text("Stock Price (15 Min. delay)")
Spacer()
Text("\(stockPrice)")
.onAppear() {
self.updateStockPrice(stock: 50)
}
}
}
}
public func updateStockPrice(stock: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
DispatchQueue.main.async {
self.stockPrice = Double(stock)
}
}
}
}
// Assume function updateView is in another file.
func updateView() {
ContentView().stockPrice = 60.0
}
You have the following three problems with your code:
As pawello2222 mentioned, you need to set stockPrice on the same ContentView object that is being displayed. You will find it either in your AppDelegate or SceneDelegate.
#State does not publish changes. You need a #Published variable in an #ObservableObject.
onAppear() gets called when the view is updated on binding variable change. And so, your value will get overwritten. You need to find another place to call updateStockPrice() - the view initializer perhaps.
The following code snippet works in Playground. But depending on your real-world context, you will have to fix the above problems appropriately.
import SwiftUI
import PlaygroundSupport
class Model: ObservableObject {
#Published var stockPrice: Double = 0.0
}
struct ContentView: View {
#ObservedObject var model = Model()
init() {
self.updateStockPrice(stock: 50)
}
var body: some View {
List {
HStack {
Text("Stock Price (15 Min. delay)")
Spacer()
Text("\(model.stockPrice)")
}
}
}
public func updateStockPrice(stock: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
DispatchQueue.main.async {
self.model.stockPrice = Double(stock)
}
}
}
}
// Assume function updateView is in another file.
func updateView(_ view: ContentView) {
view.model.stockPrice = 60.0
}
let view = ContentView()
updateView(view)
PlaygroundPage.current.setLiveView(view)

SwiftUI View not updating based on #ObservedObject

In the following code, an observed object is updated but the View that observes it is not. Any idea why?
The code presents on the screen 10 numbers (0..<10) and a button. Whenever the button is pressed, it randomly picks one of the 10 numbers and flips its visibility (visible→hidden or vice versa).
The print statement shows that the button is updating the numbers, but the View does not update accordingly. I know that updating a value in an array does not change the array value itself, so I use a manual objectWillChange.send() call. I would have thought that should trigger the update, but the screen never changes.
Any idea? I'd be interested in a solution using NumberLine as a class, or as a struct, or using no NumberLine type at all and instead rather just using an array variable within the ContentView struct.
Here's the code:
import SwiftUI
struct ContentView: View {
#ObservedObject var numberLine = NumberLine()
var body: some View {
VStack {
HStack {
ForEach(0 ..< numberLine.visible.count) { number in
if self.numberLine.visible[number] {
Text(String(number)).font(.title).padding(5)
}
}
}.padding()
Button(action: {
let index = Int.random(in: 0 ..< self.numberLine.visible.count)
self.numberLine.objectWillChange.send()
self.numberLine.visible[index].toggle()
print("\(index) now \(self.numberLine.visible[index] ? "shown" : "hidden")")
}) {
Text("Change")
}.padding()
}
}
}
class NumberLine: ObservableObject {
var visible: [Bool] = Array(repeatElement(true, count: 10))
}
With #ObservedObject everything's fine... let's analyse...
Iteration 1:
Take your code without changes and add just the following line (shows as text current state of visible array)
VStack { // << right below this
Text("\(numberLine.visible.reduce(into: "") { $0 += $1 ? "Y" : "N"} )")
and run, and you see that Text is updated so observable object works
Iteration 2:
Remove self.numberLine.objectWillChange.send() and use instead default #Published pattern in view model
class NumberLinex: ObservableObject {
#Published var visible: [Bool] = Array(repeatElement(true, count: 10))
}
run and you see that update works the same as on 1st demo above.
*But... main numbers in ForEach still not updated... yes, because problem in ForEach - you used constructor with Range that generates constant view's group by-design (that documented!).
!! That is the reason - you need dynamic ForEach, but for that model needs to be changed.
Iteration 3 - Final:
Dynamic ForEach constructor requires that iterating data elements be identifiable, so we need struct as model and updated view model.
Here is final solution & demo (tested with Xcode 11.4 / iOS 13.4)
struct ContentView: View {
#ObservedObject var numberLine = NumberLine()
var body: some View {
VStack {
HStack {
ForEach(numberLine.visible, id: \.id) { number in
Group {
if number.visible {
Text(String(number.id)).font(.title).padding(5)
}
}
}
}.padding()
Button("Change") {
let index = Int.random(in: 0 ..< self.numberLine.visible.count)
self.numberLine.visible[index].visible.toggle()
}.padding()
}
}
}
class NumberLine: ObservableObject {
#Published var visible: [NumberItem] = (0..<10).map { NumberItem(id: $0) }
}
struct NumberItem {
let id: Int
var visible = true
}
I faced the same issue.
For me, replacing #ObservedObject with #StateObject worked.
Using your insight, #Asperi, that the problem is with the ForEach and not with the #ObservableObject functionality, here's a small modification to the original that does the trick:
import SwiftUI
struct ContentView: View {
#ObservedObject var numberLine = NumberLine()
var body: some View {
VStack {
HStack {
ForEach(Array(0..<10).filter {numberLine.visible[$0]}, id: \.self) { number in
Text(String(number)).font(.title).padding(5)
}
}.padding()
Button(action: {
let index = Int.random(in: 0 ..< self.numberLine.visible.count)
self.numberLine.visible[index].toggle()
}) {
Text("Change")
}.padding()
}
}
}
class NumberLine: ObservableObject {
#Published var visible: [Bool] = Array(repeatElement(true, count: 10))
}
There is nothing Wrong with observed object, you should use #Published in use of observed object, but my code works without it as well. And also I updated your logic in your code.
import SwiftUI
struct ContentView: View {
#ObservedObject var model = NumberLineModel()
#State private var lastIndex: Int?
var body: some View {
VStack(spacing: 30.0) {
HStack {
ForEach(0..<model.array.count) { number in
if model.array[number] {
Text(String(number)).padding(5)
}
}
}
.font(.title).statusBar(hidden: true)
Group {
if let unwrappedValue: Int = lastIndex { Text("Now the number " + unwrappedValue.description + " is hidden!") }
else { Text("All numbers are visible!") }
}
.foregroundColor(Color.red)
.font(Font.headline)
Button(action: {
if let unwrappedIndex: Int = lastIndex { model.array[unwrappedIndex] = true }
let newIndex: Int = Int.random(in: 0...9)
model.array[newIndex] = false
lastIndex = newIndex
}) { Text("shuffle") }
}
}
}
class NumberLineModel: ObservableObject {
var array: [Bool] = Array(repeatElement(true, count: 10))
}
The problem is with the function, do not forget to add id: \.self in your ForEach function, and make your Model Hashable, Identifiable.

How do display event cards when push a button

I'm doing reminder IOS app.How do display event card push a button?
event add view.[Home.swift]
[Home.push plus circle button open this view.]1
event card display on Home.[EventList.swift]
[event cards.this cards do display on Home.swift]2
I tried save tapCount to userDefaults.in EventList.swift get tapCount.Because use ForEach scope.
Home.swift
var tapCount = 0
Button(action: {}) {
tapCount += 1
UserDefaults.standard.set.....
}
EventList.swift
var body: some View {
let tapCount: Int16 = UserDefault.standard.Integer(...) as! Int16
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(0 ..< tapCount) { item in
EventView()
}
}
}
}
But not to do.please teach it.
My yesterday solution for using UserDefaults.standart in SwiftUI:
made final class UserData with needed variable;
set this variable from UserDefaults at the init();
set #EnvironmentObject var of UserData class at needed View;
some quick example:
import SwiftUI
final class UserData: ObservableObject {
#Published var tapCount: Int? { // remember, it can be nil at the first launch!
didSet {
UserDefaults.standard.set(tapCount, forKey: "tapCount")
}
}
init() {
// Sure you can set it to 0 with UserDefaults.standard.integer(forKey: "tapCount") ?? 0, but be careful with your ForEach loop
self.tapCount = UserDefaults.standard.integer(forKey: "tapCount")
}
}
// how to use:
struct SomeView: View {
#EnvironmentObject var userData: UserDataSettings
var body: some View {
if self.userData.tapCount == nil {
return AnyView(Text("no taps!"))
} else {
return AnyView(ScrollView(.horizontal, showsIndicators: false) {
HStack {
// you need to check for nil, else you app may crash. this if...else variant should work
ForEach(0 ..< userData.tapCount!) { item in
Text("\(item)")
}
}
})
}
}
}
and the last thing: you need to set .environmentObject(UserData()) when display your view (in SceneDelegate for example, if this is the first view) and then bring this userData variable to other child views)

SwiftUI View - viewDidLoad()?

Trying to load an image after the view loads, the model object driving the view (see MovieDetail below) has a urlString. Because a SwiftUI View element has no life cycle methods (and there's not a view controller driving things) what is the best way to handle this?
The main issue I'm having is no matter which way I try to solve the problem (Binding an object or using a State variable), my View doesn't have the urlString until after it loads...
// movie object
struct Movie: Decodable, Identifiable {
let id: String
let title: String
let year: String
let type: String
var posterUrl: String
private enum CodingKeys: String, CodingKey {
case id = "imdbID"
case title = "Title"
case year = "Year"
case type = "Type"
case posterUrl = "Poster"
}
}
// root content list view that navigates to the detail view
struct ContentView : View {
var movies: [Movie]
var body: some View {
NavigationView {
List(movies) { movie in
NavigationButton(destination: MovieDetail(movie: movie)) {
MovieRow(movie: movie)
}
}
.navigationBarTitle(Text("Star Wars Movies"))
}
}
}
// detail view that needs to make the asynchronous call
struct MovieDetail : View {
let movie: Movie
#State var imageObject = BoundImageObject()
var body: some View {
HStack(alignment: .top) {
VStack {
Image(uiImage: imageObject.image)
.scaledToFit()
Text(movie.title)
.font(.subheadline)
}
}
}
}
We can achieve this using view modifier.
Create ViewModifier:
struct ViewDidLoadModifier: ViewModifier {
#State private var didLoad = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content.onAppear {
if didLoad == false {
didLoad = true
action?()
}
}
}
}
Create View extension:
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
Use like this:
struct SomeView: View {
var body: some View {
VStack {
Text("HELLO!")
}.onLoad {
print("onLoad")
}
}
}
I hope this is helpful. I found a blogpost that talks about doing stuff onAppear for a navigation view.
Idea would be that you bake your service into a BindableObject and subscribe to those updates in your view.
struct SearchView : View {
#State private var query: String = "Swift"
#EnvironmentObject var repoStore: ReposStore
var body: some View {
NavigationView {
List {
TextField($query, placeholder: Text("type something..."), onCommit: fetch)
ForEach(repoStore.repos) { repo in
RepoRow(repo: repo)
}
}.navigationBarTitle(Text("Search"))
}.onAppear(perform: fetch)
}
private func fetch() {
repoStore.fetch(matching: query)
}
}
import SwiftUI
import Combine
class ReposStore: BindableObject {
var repos: [Repo] = [] {
didSet {
didChange.send(self)
}
}
var didChange = PassthroughSubject<ReposStore, Never>()
let service: GithubService
init(service: GithubService) {
self.service = service
}
func fetch(matching query: String) {
service.search(matching: query) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let repos): self?.repos = repos
case .failure: self?.repos = []
}
}
}
}
}
Credit to: Majid Jabrayilov
Fully updated for Xcode 11.2, Swift 5.0
I think the viewDidLoad() just equal to implement in the body closure.
SwiftUI gives us equivalents to UIKit’s viewDidAppear() and viewDidDisappear() in the form of onAppear() and onDisappear(). You can attach any code to these two events that you want, and SwiftUI will execute them when they occur.
As an example, this creates two views that use onAppear() and onDisappear() to print messages, with a navigation link to move between the two:
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView()) {
Text("Hello World")
}
}
}.onAppear {
print("ContentView appeared!")
}.onDisappear {
print("ContentView disappeared!")
}
}
}
ref: https://www.hackingwithswift.com/quick-start/swiftui/how-to-respond-to-view-lifecycle-events-onappear-and-ondisappear
I'm using init() instead. I think onApear() is not an alternative to viewDidLoad(). Because onApear is called when your view is being appeared. Since your view can be appear multiple times it conflicts with viewDidLoad which is called once.
Imagine having a TabView. By swiping through pages onApear() is being called multiple times. However viewDidLoad() is called just once.

Resources