I recently faced screen flickering in my SwiftUI app whenever I switched tabs and came back. Caching remote images wasn't implemented yet, and although it's basic functionality, its absence revealed a bug where data was fetched on every viewWillAppear. This is, of course, not desirable. The data for the screen should be loaded once and remain even when navigating away and back to the screen.
Here's how it looked:
The data was fetched in the .task modifier:
.task { viewModel.fetch() }
It turned out .task is triggered every time a tab is changed or reselected, behaving exactly like viewDidAppear in UIKit.
Missing viewDidLoad
The problem is that SwiftUI lacks a viewDidLoad alternative that is called only once. Methods like .onAppear or .task trigger their closures every time a view appears, leading to multiple executions of my data fetching logic. This results in unnecessary network calls and a poor user experience with screen flickering.
Iterating for Solution #1: ViewDidLoadModifier
To solve this, we could use ViewDidLoadModifier to ensure that a specified action is only executed once when the view first appears, mimicking the viewDidLoad behavior from UIKit.
import SwiftUI
public struct ViewDidLoadModifier: ViewModifier {
@State private var viewDidLoad = false
let action: (() -> Void)?
public func body(content: Content) -> some View {
content
.onAppear {
if viewDidLoad == false {
viewDidLoad = true
action?()
}
}
}
}
public extension View
func onViewDidLoad(perform action: (() -> Void)? = nil) -> some View {
self.modifier(ViewDidLoadModifier(action: action))
}
}
State Management: The ViewDidLoadModifier uses a @State property to track if the view has appeared before. This state is local to each instance of the view, ensuring that the action is only executed once per view lifecycle.
Conditional Execution: Inside the onAppear modifier, it checks if viewDidLoad is false. If so, it sets viewDidLoad to true and executes the provided action. Subsequent appearances of the view do not trigger the action again.
Applying this modifier to a view like this:
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.onViewDidLoad {
print("View did load")
viewModel.fetch()
}
}
}
Semantic Issue
There is a semantic issue with this solution:
In UIKit, viewDidLoad is called before viewDidAppear. However, in this implementation, we create the viewDidLoad behavior after viewDidAppear, as viewDidAppear is actually triggering our function. Therefore, the name onViewDidLoad is somewhat inappropriate. Some other alternatives could be performOnceOnViewDidAppear, executeOnceOnAppear, or runOnceOnViewAppear. Let's go with the second one:
Iterating for solution #2: ExecuteOnceOnAppearModifier
import SwiftUI
public struct ExecuteOnceOnAppearModifier: ViewModifier {
@State private var hasAppeared = false
let action: (() -> Void)?
public func body(content: Content) -> some View {
content
.onAppear {
if hasAppeared == false {
hasAppeared = true
action?()
}
}
}
}
public extension View {
func executeOnceOnAppear(perform action: (() -> Void)? = nil) -> some View {
self.modifier(ExecuteOnceOnAppearModifier(action: action))
}
}
Use:
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.executeOnceOnAppear {
print("View did load")
viewModel.fetch()
}
}
}
Final Result:
Example of use: https://github.com/fuxlud/Modularized_iOS_App
Comentarios