26

Basically I try to figure out when my viewModel get updated, it will notify view and it will refresh whole body. How to avoid that. For example if my view GoLiveView already present another view BroadcasterView, and later my goLiveViewModel get updated, GoLiveView will be refreshed, and it will create BroadcasterView again , because showBroadcasterView = true. And it will cause so many issues down the road, because of that.

struct GoLiveView: View {

@ObservedObject var goLiveViewModel = GoLiveViewModel()
@EnvironmentObject var sessionStore: SessionStore
@State private var showBroadcasterView = false
@State private var showLiveView = false

init() {
    goLiveViewModel.refresh()
}

var body: some View {
    NavigationView {
        List(goLiveViewModel.rooms) { room in // when goLiveViewModed get updated 
            NavigationLink(destination: LiveView(clientRole: .audience, room: room, showLiveView: $showLiveView))) {
                LiveCell(room: room)

            }
        }.background(Color.white)
        .navigationBarTitle("Live", displayMode: .inline)
        .navigationBarItems(leading:
            Button(action: {
                self.showBroadcasterView = true
        }, label: {
            Image("ic_go_live").renderingMode(.original)
        })).frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color(red: 34/255, green: 34/255, blue: 34/255))

        .sheet(isPresented: $showBroadcasterView) { // here is problem, get called many times, hence reload whole body ,and create new instances of BroadcasterView(). Because showBroadcasterView = is still true.

                BroadcasterView(broadcasterViewModel: BroadcasterViewModel(showBroadcasterView: $showBroadcasterView))
                    .environmentObject(self.sessionStore)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.clear)
            }

    }
}

this is my GoliveViewModel

typealias RoomsFetchOuput = AnyPublisher<RoomsFetchState, Never>

enum RoomsFetchState: Equatable {

    static func == (lhs: RoomsFetchState, rhs: RoomsFetchState) -> Bool {
        switch (lhs, rhs) {
        case (.loading, .loading): return true
        case (.success(let lhsrooms), .success(let rhsrooms)):
            return lhsrooms == rhsrooms
        case (.noResults, .noResults): return true
        case (.failure, .failure): return true
        default: return false
        }
    }

    case loading
    case success([Room])
    case noResults
    case failure(Error)
}
class GoLiveViewModel: ObservableObject {

    private lazy var webServiceManager = WebServiceManager()
    @Published var rooms = [Room]()
    private lazy var timer = Timer()
    private var cancellables: [AnyCancellable] = []

    init() {
        timer = Timer.scheduledTimer(timeInterval: 4.0, target: self, selector: #selector(refresh) , userInfo: nil, repeats: true) //  call every 4 second refresh
    }

    func fetch() -> RoomsFetchOuput {
        return webServiceManager.fetchAllRooms()
            .map ({ result -> RoomsFetchState in
                switch result {
                case .success([]): return .noResults
                case let .success(rooms): return .success(rooms)
                case .failure(let error): return .failure(error)
                }
            })
            .eraseToAnyPublisher()

        let isLoading: RoomsFetchOuput = .just(.loading)
        let initialState: RoomsFetchOuput = .just(.noResults)

        let idle: RoomsFetchOuput = Publishers.Merge(isLoading, initialState).eraseToAnyPublisher()

        return Publishers.Merge(idle, rooms).removeDuplicates().eraseToAnyPublisher()

    }

    @objc func refresh() {
         cancellables.forEach { $0.cancel() }
          cancellables.removeAll()
        fetch()
            .sink { [weak self] state in
                guard let self = self else { return }
                switch state {
                case let .success(rooms):
                    self.rooms = rooms
                case .failure: print("failure")
                // show error alert to user
                case .noResults: print("no result")
                self.rooms = []
                // hide spinner
                case .loading:  print(".loading")
                    // show spinner
                }
        }
        .store(in: &cancellables)
    }
}
Hrabovskyi Oleksandr
  • 3,070
  • 2
  • 17
  • 36
Vkukjans
  • 363
  • 1
  • 3
  • 8

2 Answers2

72

SwfitUI has a pattern for this. It needs to conform custom view to Equatable protocol

struct CustomView: View, Equatable {

    static func == (lhs: CustomView, rhs: CustomView) -> Bool {
        // << return yes on view properties which identifies that the
        // view is equal and should not be refreshed (ie. `body` is not rebuilt)
    }
...

and in place of construction add modifier .equatable(), like

var body: some View {
      CustomView().equatable()
}

yes, new value of CustomView will be constructed every time as superview refreshing (so don't make init heavy), but body will be called only if newly constructed view is not equal of previously constructed

Finally, it is seen that it is very useful to break UI hierarchy to many views, it would allow to optimise refresh a lot (but not only good design, maintainability, reusability, etc. :^) ).

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    Had a bug that led to rerendering a view with a picker, each time the user made a selection. This in turn led to the picker being visually reset to its initial values after the user made the selection. Your solution is fabulous and I wasn't aware of this build in feature until now – Seitenwerk May 13 '20 at 11:09
  • 5
    Thank you. This solution gave me answers to random hang up while switching UI. I also found this article is explained in detail - https://swiftui-lab.com/equatableview/ – Thein Jul 31 '20 at 13:53
  • @Seitenwerk What exactly was your problem, and how did you solve it. I think I ran into a similar issue. I'm currently using SwiftUIFlux, which automatically updates the date of the picker, once it gets changed (also manually), which therefore leads to the picker to close immediately. So I somehow need to break this update cycle, but didn't found a working solution yet. – d4Rk Aug 29 '20 at 10:15
  • 12
    This solution doesn’t works if you use `@EnvironmentObject` in a `View` – Hattori Hanzō Nov 17 '20 at 12:33
  • I'm using @EnvironmentObject in my view and I don't believe that's the reason for the problem. In my tests, what happens is that in my View I have a List with a section whose content could be affected by a Published from the model. If the section content is not dynamic, it worked as expected. So the answer using equatable() is correct. – Douglas Frari Jan 18 '23 at 12:58
  • Anybody looking at this post should watch [Demystify SwiftUI](https://developer.apple.com/wwdc21/10022) – lorem ipsum Jul 20 '23 at 13:22
-1
struct CustomView: View, Equatable {
    static func == (lhs: CustomView, rhs: CustomView) -> Bool {
        // << return yes on view properties which identifies that the
        // view is equal and should not be refreshed (ie. `body` is not rebuilt)
    }

    var body: some View {
        CustomView().equatable()
    }
}

extension View {
    func equatable<Content: View & Equatable>() -> EquatableView<Content> {
      return EquatableView(content: self as! Content)
    }
}
Darotudeen
  • 1,914
  • 4
  • 21
  • 36