2

I'm struggling to understand why a PageViewController does not work as expected when there is a change to a @Published EnvironmentObject variable during .onAppear in its parent View.

If I comment out the .onAppear modifier in DetailView, then everything works fine - the slides swipe as expected of a PageViewController. But as soon as that modifier is back in, then the pages do not swipe correctly.

It's got to be something to do with the re-render due to state change, but I can't figure it out. I know that updateUIViewController is called on the state change, and this seems to change the array of viewControllers.

If anyone has a steer on how to solve this, I'll be really grateful to hear about it!

If you are running the code below, make sure to change your SceneDelegate:

let contentView = ContentView()

to

let contentView = ContentView().environmentObject(Global())

So here's a simple example of the issue:

import SwiftUI
import Foundation
import UIKit

struct Item: Hashable {
  var text: String
}

let testItems: [Item] = [
  Item(text: "I am item 1"),
  Item(text: "I am item 2"),
  Item(text: "I am item 3")
]

final class Global : ObservableObject {
  @Published var hideText: Bool = false
}

struct ContentView: View {

  @EnvironmentObject var global: Global

  var body: some View {

    NavigationView {
      VStack {
        List(testItems, id: \.self) { item in
          NavigationLink(destination: DetailView(item: item)) {
            Text(item.text)
          }
        }

        Text("I should vanish when detail view loads")
          .opacity(self.global.hideText ? 0 : 1)
      }
      .navigationBarTitle("Test")
    }
  }
}

struct DetailView: View {

  @EnvironmentObject var global: Global

  var item: Item
  var testViews: [Text] = [
    Text("Slide 1").font(.title),
    Text("Slide 2").font(.title),
    Text("Slide 3").font(.title),
  ]

  var body: some View {

    // The .onAppear modifier breaks the PageViewController
    // when it is commented out, the slides work as expected

    PageView(testViews)
      .onAppear {
        self.global.hideText = true
      }
  }
}

struct PageView<Page: View>: View {

  @State var currentPage = 0

  var viewControllers: [UIHostingController<Page>]

  init(_ views: [Page]) {
    self.viewControllers = views.map{ UIHostingController(rootView: $0) }
  }

  var body: some View {
    VStack(alignment: .center) {
      PageViewController(
        viewControllers: viewControllers,
        currentPageIndex: $currentPage
      )
    }
  }
}

struct PageViewController: UIViewControllerRepresentable {

  var viewControllers: [UIViewController]

  @Binding var currentPageIndex: Int

  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }

  func makeUIViewController(context: Context) -> UIPageViewController {
    let pageViewController = UIPageViewController(
      transitionStyle: .scroll,
      navigationOrientation: .horizontal
    )

    pageViewController.view.backgroundColor = UIColor.clear
    pageViewController.dataSource = context.coordinator
    pageViewController.delegate = context.coordinator

    return pageViewController
  }

  func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
    pageViewController.setViewControllers([viewControllers[currentPageIndex]], direction: .forward, animated: true)
  }

  class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {

    var parent: PageViewController

    init(_ pageViewController: PageViewController) {
      self.parent = pageViewController
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        // retrieves the index of the currently displayed view controller
        guard let index = parent.viewControllers.firstIndex(of: viewController) else {
          return nil
        }

        // shows the last view controller when the user swipes back from the first view controller
        if index == 0 {
          return parent.viewControllers.last
        }

        //show the view controller before the currently displayed view controller
        return parent.viewControllers[index - 1]
      }

      func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
          // retrieves the index of the currently displayed view controller
          guard let index = parent.viewControllers.firstIndex(of: viewController) else {
            print("View controller not found!!")
            return nil
          }
          // shows the first view controller when the user swipes further from the last view controller
          if index + 1 == parent.viewControllers.count {
            return parent.viewControllers.first
          }

          // show the view controller after the currently displayed view controller
          return parent.viewControllers[index + 1]
      }

      func pageViewController(_ pageViewController: UIPageViewController,
        didFinishAnimating finished: Bool,
        previousViewControllers: [UIViewController],
        transitionCompleted completed: Bool
      ) {
        if completed,
          let visibleViewController = pageViewController.viewControllers?.first,
          let index = parent.viewControllers.firstIndex(of: visibleViewController) {
            parent.currentPageIndex = index
          }
      }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
      .environmentObject(Global())
  }
}
codewithfeeling
  • 6,236
  • 6
  • 41
  • 53

2 Answers2

2

This builds on a helpful answer by @Mac3n, who correctly pointed out that making the ViewControllers passed to the UIViewControllerRepresentable part of state fixes the issue. All that was missing was how to actually do that via the init method.

Here is the full update to PageView

struct PageView<Page: View>: View {

  @State var currentPage = 0
  @State var viewControllers: [UIHostingController<Page>]

  init(_ views: [Page]) {
    _viewControllers = State(initialValue: views.map{ UIHostingController(rootView: $0) })
  }

  var body: some View {
    VStack(alignment: .center) {
      PageViewController(
        viewControllers: viewControllers,
        currentPageIndex: $currentPage
      )
    }
  }
}
codewithfeeling
  • 6,236
  • 6
  • 41
  • 53
1

When you change EnvironmentObject variable during .onAppear in its parent view, it renders the view again and I guess values update with new objects. If you set a breakpoint in your func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? method and get po in your console, you can see the memory address for the views are different and because of that it couldn't found the next view controller

parent:
(lldb) po parent
▿ PageViewController
  ▿ viewControllers : 3 elements
    ▿ 0 : <_TtGC7SwiftUI19UIHostingControllerVS_4Text_: 0x7fc881e04b10>
    ▿ 1 : <_TtGC7SwiftUI19UIHostingControllerVS_4Text_: 0x7fc881f13f70>
    ▿ 2 : <_TtGC7SwiftUI19UIHostingControllerVS_4Text_: 0x7fc881f14dd0>

(lldb) po viewController
<_TtGC7SwiftUI19UIHostingControllerVS_4Text_: 0x7fc881f186c0>

as you can see, the view controller could not found in parent view controllers

For a solution, if you change your PageView to this, it solves the problem but It could be other solution too.

struct PageView: View {

  @State var currentPage = 0
  @State var viewControllers: [UIViewController]

  var body: some View {
    VStack(alignment: .center) {
      PageViewController(
        viewControllers: viewControllers,
        currentPageIndex: $currentPage
      )
    }
  }
}

change your viewControllers to be @State and pass it form parent view

Mac3n
  • 4,189
  • 3
  • 16
  • 29
  • Thanks - this does indeed solve the problem. I have posted another answer which fully completes the solution by actually populating the state in the `init` method. I will mark your answer as accepted since it was very helpful and pointed me to the full solution. Thanks again. – codewithfeeling Feb 15 '20 at 21:58