In my app, I use the onChange()
method and a publisher to detect and notify of available screen width change. Also I use the innerWorkInProgress
published property in my view model to control view's opacity. The view’s opacity is set to 0 on screen width change. Once content of the view has been updated, its opacity is set back to 1 in the onAppear()
method.
My problem is that the onReceive()
method won’t execute if I set the value to innerWorkInProgress
in the onChange()
method.
If I use a state variable instead of the published property, the onReceive()
method works well.
What’s wrong with my code?
Below is a simplified reproducible example. You can test it by rotating your device from portrait to landscape and vice versa.
ContentView
import SwiftUI
struct ContentView: View {
@StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
NavigationView {
VStack {
MyView(viewModel: viewModel)
Spacer()
Text("Another view")
}
.navigationBarTitle("Hey!", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
MyView
import SwiftUI
import Combine
struct MyView: View {
@ObservedObject var viewModel: ViewModel
@State private var initFlag = false
let widthChangeDetector: CurrentValueSubject<CGFloat, Never>
let widthChangePublisher: AnyPublisher<CGFloat, Never>
init(viewModel: ViewModel){
self.viewModel = viewModel
let wDetector = CurrentValueSubject<CGFloat, Never>(0)
self.widthChangePublisher = wDetector
.debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.widthChangeDetector = wDetector
}
var body: some View {
GeometryReader { geometry in
ScrollView {
// ForEach along with the viewModel.jIndex published property creates
// a new instance of Text view every time viewModel.jIndex is updated.
// In this way, we can make use of Text's onAppear on each instance newly created.
ForEach((viewModel.jIndex-1..<viewModel.jIndex), id: \.self) { i in
Text(viewModel.textData)
.id(viewModel.jIndex)
.opacity(viewModel.innerWorkInProgress ? 0 : 1)
.onAppear {
withAnimation(.easeInOut(duration: 0.3)) {
viewModel.innerWorkInProgress = false
}
}
.onChange(of: geometry.size.width) { newWidth in
if !initFlag {
initFlag = true
return
}
// Problem: this line prevents onReceive from executing
viewModel.innerWorkInProgress = true
widthChangeDetector.send(newWidth)
}
.onReceive(widthChangePublisher) { finalWidth in
print("onReceive, FINAL WIDTH = \(finalWidth)")
Task {
await viewModel.loadData()
}
}
}
}
.onAppear {
Task {
await viewModel.loadData()
}
}
}
}
}
ViewModel
import Foundation
final class ViewModel: ObservableObject {
@Published var innerWorkInProgress = false
@Published private(set) var jIndex = 0
private(set) var textData = ""
func loadData() async {
innerWorkInProgress = true
let i = jIndex + 1
textData = "#\(i) Lorem ipsum dolor sit amet, consectetur adipiscing elit"
jIndex = i
}
}
Here is an alternative version of MyView, with the onChangeEventInProgress
state property being used instead of the published property. This version works without any problems.
import SwiftUI
import Combine
struct MyView: View {
@ObservedObject var viewModel: ViewModel
// a state property used instead of a published property
@State private var onChangeEventInProgress = false
@State private var initFlag = false
let widthChangeDetector: CurrentValueSubject<CGFloat, Never>
let widthChangePublisher: AnyPublisher<CGFloat, Never>
init(viewModel: ViewModel){
self.viewModel = viewModel
let wDetector = CurrentValueSubject<CGFloat, Never>(0)
self.widthChangePublisher = wDetector
.debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)
.dropFirst()
.eraseToAnyPublisher()
self.widthChangeDetector = wDetector
}
var body: some View {
GeometryReader { geometry in
ScrollView {
ForEach((viewModel.jIndex-1..<viewModel.jIndex), id: \.self) { i in
Text(viewModel.textData)
.id(viewModel.jIndex)
.opacity(onChangeEventInProgress ? 0 : 1)
.onAppear {
withAnimation(.easeInOut(duration: 0.3)) {
onChangeEventInProgress = false
}
}
.onChange(of: geometry.size.width) { newWidth in
if !initFlag {
initFlag = true
return
}
// This line doesn't cause any problems
onChangeEventInProgress = true
widthChangeDetector.send(newWidth)
}
.onReceive(widthChangePublisher) { finalWidth in
print("onReceive, FINAL WIDTH = \(finalWidth)")
Task {
await viewModel.loadData()
}
}
}
}
.onAppear {
Task {
await viewModel.loadData()
}
}
}
}
}
The solution is to move the GeometryReader block outside of NavigationView. As a result, the onChange() method is called just once on a descendant view. This allows getting rid of widthChangeDetector, widthChangePublisher, and the onReceive(widthChangePublisher) method at all.
Below is the modified code.
ContentView
import SwiftUI
struct ContentView: View {
@StateObject var viewModel: ViewModel = ViewModel()
var body: some View {
GeometryReader { geometry in
NavigationView {
VStack {
MyView(geometryProxy: geometry, viewModel: viewModel)
Spacer()
Text("Another view")
}
.navigationBarTitle("Hey!", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
MyView
import SwiftUI
struct MyView: View {
@ObservedObject var viewModel: ViewModel
private var geometryProxy: GeometryProxy
init(geometryProxy: GeometryProxy, viewModel: ViewModel){
self.geometryProxy = geometryProxy
self.viewModel = viewModel
}
var body: some View {
ScrollView {
// ForEach along with the viewModel.jIndex published property creates
// a new instance of Text view every time viewModel.jIndex is updated.
// In this way, we can make use of Text's onAppear on each instance newly created.
ForEach(((viewModel.jIndex == 0 ? 0 : viewModel.jIndex-1)..<viewModel.jIndex), id: \.self) { i in
Text(viewModel.textData)
.id(i)
.opacity(viewModel.innerWorkInProgress ? 0 : 1)
.onAppear {
withAnimation(.easeInOut(duration: 0.3)) {
viewModel.innerWorkInProgress = false
}
}
}
}
.onAppear {
Task {
await viewModel.loadData()
}
}
.onChange(of: geometryProxy.size.width) { newWidth in
viewModel.innerWorkInProgress = true
Task {
await viewModel.loadData()
}
}
}
}