Search code examples
swiftuionchangepublisher

onReceive method on SwiftUI view won’t execute for a custom publisher


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()
                }
            }
        }
    }
}

Solution

  • 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()
                }
            }
        }
    }