Search code examples
swiftuiuiviewcontrolleruiscrollviewscrollviewswiftui-navigationlink

Why does NavigationLink buttons appear "disabled" in a custom UIViewControllerRepresentable wrapper


I have created a wrapper that conforms to UIViewControllerRepresentable. I have created a UIViewController which contains a UIScrollView that has paging enabled. The custom wrapper works as it should.

SwiftyUIScrollView(.horizontal, pagingEnabled: true) {
          NavigationLink(destination: Text("This is a test")) {
                 Text("Navigation Link Test")
          }
}

This button appears disabled and greyed out. Clicking it does nothing. However, if the same button is put inside a ScrollView {} wrapper, it works.

What am I missing here. Here is the custom scrollview class code:

    enum DirectionX {
    case horizontal
    case vertical
    }

    struct SwiftyUIScrollView<Content: View>: UIViewControllerRepresentable {
    var content: () -> Content
    var axis: DirectionX
    var numberOfPages = 0
    var pagingEnabled: Bool = false
    var pageControlEnabled: Bool = false
    var hideScrollIndicators: Bool = false

    init(axis: DirectionX, numberOfPages: Int, pagingEnabled: Bool, 
     pageControlEnabled: Bool, hideScrollIndicators: Bool, @ViewBuilder content: 
     @escaping () -> Content) {
        self.content = content
        self.numberOfPages = numberOfPages
        self.pagingEnabled = pagingEnabled
        self.pageControlEnabled = pageControlEnabled
        self.hideScrollIndicators = hideScrollIndicators
        self.axis = axis
    }

    func makeUIViewController(context: Context) -> UIScrollViewController {
        let vc = UIScrollViewController()
        vc.axis = axis
        vc.numberOfPages = numberOfPages
        vc.pagingEnabled = pagingEnabled
        vc.pageControlEnabled = pageControlEnabled
        vc.hideScrollIndicators = hideScrollIndicators
        vc.hostingController.rootView = AnyView(self.content())
        return vc
    }

    func updateUIViewController(_ viewController: UIScrollViewController, context: Context) {
        viewController.hostingController.rootView = AnyView(self.content())
    }
    }

    class UIScrollViewController: UIViewController, UIScrollViewDelegate {
    
    var axis: DirectionX = .horizontal
    var numberOfPages: Int = 0
    var pagingEnabled: Bool = false
    var pageControlEnabled: Bool = false
    var hideScrollIndicators: Bool = false
    
    lazy var scrollView: UIScrollView = {
        let view = UIScrollView()
        view.delegate = self
        view.isPagingEnabled = pagingEnabled
        view.showsVerticalScrollIndicator = !hideScrollIndicators
        view.showsHorizontalScrollIndicator = !hideScrollIndicators
        return view
    }()
    
    lazy var pageControl : UIPageControl = {
        let pageControl = UIPageControl()
            pageControl.numberOfPages = numberOfPages
            pageControl.currentPage = 0
            pageControl.tintColor = UIColor.white
            pageControl.pageIndicatorTintColor = UIColor.gray
            pageControl.currentPageIndicatorTintColor = UIColor.white
            pageControl.translatesAutoresizingMaskIntoConstraints = false
            pageControl.isHidden = !pageControlEnabled
        return pageControl
    }()
    
    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(scrollView)
        self.makefullScreen(of: self.scrollView, to: self.view)

        self.hostingController.willMove(toParent: self)
        self.scrollView.addSubview(self.hostingController.view)
        self.makefullScreen(of: self.hostingController.view, to: self.scrollView)
        self.hostingController.didMove(toParent: self)
        
        view.addSubview(pageControl)
        pageControl.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50).isActive = true
        pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        pageControl.heightAnchor.constraint(equalToConstant: 60).isActive = true
        pageControl.widthAnchor.constraint(equalToConstant: 200).isActive = true
    }
    
    func makefullScreen(of viewA: UIView, to viewB: UIView) {
          viewA.translatesAutoresizingMaskIntoConstraints = false
          viewB.addConstraints([
              viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
              viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
              viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
              viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
          ])
      }
    
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    
            let currentIndexHorizontal = round(scrollView.contentOffset.x / self.view.frame.size.width)
            let currentIndexVertical = round(scrollView.contentOffset.y / self.view.frame.size.height)
        
        switch axis {
        case .horizontal:
             self.pageControl.currentPage = Int(currentIndexHorizontal)
            break
        case .vertical:
            self.pageControl.currentPage = Int(currentIndexVertical)
            break
        default:
            break
        }
     }
}

UPDATE

This is how I am using the wrapper:

struct TestData {
    var id : Int
    var text: String
}

struct ContentView: View {
var contentArray: [TestData] = [TestData(id: 0, text: "Test 1"), TestData(id: 1, text: "Test 2"), TestData(id: 2, text: "TEst 3"), TestData(id: 4, text: "Test 4")]
    
    
var body: some View {
        NavigationView {
          GeometryReader { g in
            ZStack{
            SwiftyUIScrollView(axis: .horizontal, numberOfPages: self.contentArray.count, pagingEnabled: true, pageControlEnabled: true, hideScrollIndicators: true) {
                        HStack(spacing: 0) {
                            ForEach(self.contentArray, id: \.id) { item in
                                TestView(data: item)
                                    .frame(width: g.size.width, height: g.size.height)
                            }
                        }
                }.frame(width: g.size.width)
                }.frame(width: g.size.width, height: g.size.height)
                .navigationBarTitle("Test")
            }
        }
    }
}

struct TestView: View {
    var data: TestData
    var body: some View {
        GeometryReader { g in
                VStack {
                    HStack {
                        Spacer()
                    }
                    Text(self.data.text)
                    Text(self.data.text)
                    
                    VStack {
                        NavigationLink(destination: Text("This is a test")) {
                                       Text("Navigation Link Test")
                        }
                    }
                    Button(action: {
                        print("Do something")
                    }) {
                        Text("Button")
                    }
                }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                .background(Color.yellow)
            }
        }
}

The "navigation link test" button is greyed out. simulator ss


Solution

  • I spent some time with your code. I think I understand what the problem is, and found a workaround.

    The issue is, I think, that for NavigationLink to be enabled, it needs to be inside a NavigationView. Although yours is, it seems the "connection" is lost with UIHostingController. If you check the UIHostingController.navigationController, you'll see that it is nil.

    The only solution I can think of, is having a hidden NavigationLink outside the SwiftyUIScrollView that can be triggered manually (with its isActive parameter). Then inside your SwiftyUIScrollView, you should use a simple button that when tapped, changes your model to toggle the NavigationLink's isActive binding. Below is an example that seems to work fine.

    Note that NavigationLink's isActive has a small bug at the moment, but it will probably be fixed soon. To learn more about it: https://swiftui-lab.com/bug-navigationlink-isactive/

    window.rootViewController = UIHostingController(rootView: ContentView().environmentObject(MyModel()))
    
    import SwiftUI
    
    class MyModel: ObservableObject {
        @Published var navigateNow = false
    }
    
    struct TestData {
        var id : Int
        var text: String
    }
    
    
    struct ContentView: View {
        @EnvironmentObject var model: MyModel
    
        var contentArray: [TestData] = [TestData(id: 0, text: "Test 1"), TestData(id: 1, text: "Test 2"), TestData(id: 2, text: "TEst 3"), TestData(id: 4, text: "Test 4")]
    
    
        var body: some View {
            NavigationView {
                GeometryReader { g in
                    ZStack{
                        NavigationLink(destination: Text("Destination View"), isActive: self.$model.navigateNow) { EmptyView() }
    
                        SwiftyUIScrollView(axis: .horizontal, numberOfPages: self.contentArray.count, pagingEnabled: true, pageControlEnabled: true, hideScrollIndicators: true) {
                            HStack(spacing: 0) {
                                ForEach(self.contentArray, id: \.id) { item in
                                    TestView(data: item)
                                        .frame(width: g.size.width, height: g.size.height)
                                }
                            }
                        }.frame(width: g.size.width)
                    }.frame(width: g.size.width, height: g.size.height)
                        .navigationBarTitle("Test")
                }
            }
        }
    }
    
    struct TestView: View {
        @EnvironmentObject var model: MyModel
    
        var data: TestData
        var body: some View {
    
            GeometryReader { g in
                VStack {
                    HStack {
                        Spacer()
                    }
                    Text(self.data.text)
                    Text(self.data.text)
    
                    VStack {
                        Button("Pseudo-Navigation Link Test") {
                            self.model.navigateNow = true
                        }
                    }
                    Button(action: {
                        print("Do something")
                    }) {
                        Text("Button")
                    }
                }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                    .background(Color.yellow)
            }
        }
    }
    

    The other thing is your use of AnyView. It comes with a heavy performance price. It is recommended you only use AnyView with leaf views (not your case). So I did managed to refactor your code to eliminate the AnyView. See below, hope it helps.

    import SwiftUI
    
    enum DirectionX {
        case horizontal
        case vertical
    }
    
    
    struct SwiftyUIScrollView<Content: View>: UIViewControllerRepresentable {
        var content: () -> Content
        var axis: DirectionX
        var numberOfPages = 0
        var pagingEnabled: Bool = false
        var pageControlEnabled: Bool = false
        var hideScrollIndicators: Bool = false
    
        init(axis: DirectionX, numberOfPages: Int,
             pagingEnabled: Bool,
             pageControlEnabled: Bool,
             hideScrollIndicators: Bool,
             @ViewBuilder content: @escaping () -> Content) {
    
            self.content = content
            self.numberOfPages = numberOfPages
            self.pagingEnabled = pagingEnabled
            self.pageControlEnabled = pageControlEnabled
            self.hideScrollIndicators = hideScrollIndicators
            self.axis = axis
        }
    
        func makeUIViewController(context: Context) -> UIScrollViewController<Content> {
            let vc = UIScrollViewController(rootView: self.content())
            vc.axis = axis
            vc.numberOfPages = numberOfPages
            vc.pagingEnabled = pagingEnabled
            vc.pageControlEnabled = pageControlEnabled
            vc.hideScrollIndicators = hideScrollIndicators
            return vc
        }
    
        func updateUIViewController(_ viewController: UIScrollViewController<Content>, context: Context) {
            viewController.hostingController.rootView = self.content()
        }
    }
    
    class UIScrollViewController<Content: View>: UIViewController, UIScrollViewDelegate {
    
        var axis: DirectionX = .horizontal
        var numberOfPages: Int = 0
        var pagingEnabled: Bool = false
        var pageControlEnabled: Bool = false
        var hideScrollIndicators: Bool = false
    
        lazy var scrollView: UIScrollView = {
            let view = UIScrollView()
            view.delegate = self
            view.isPagingEnabled = pagingEnabled
            view.showsVerticalScrollIndicator = !hideScrollIndicators
            view.showsHorizontalScrollIndicator = !hideScrollIndicators
            return view
        }()
    
        lazy var pageControl : UIPageControl = {
            let pageControl = UIPageControl()
            pageControl.numberOfPages = numberOfPages
            pageControl.currentPage = 0
            pageControl.tintColor = UIColor.white
            pageControl.pageIndicatorTintColor = UIColor.gray
            pageControl.currentPageIndicatorTintColor = UIColor.white
            pageControl.translatesAutoresizingMaskIntoConstraints = false
            pageControl.isHidden = !pageControlEnabled
            return pageControl
        }()
    
        init(rootView: Content) {
            self.hostingController = UIHostingController<Content>(rootView: rootView)
            super.init(nibName: nil, bundle: nil)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        var hostingController: UIHostingController<Content>! = nil
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.addSubview(scrollView)
            self.makefullScreen(of: self.scrollView, to: self.view)
    
            self.hostingController.willMove(toParent: self)
            self.scrollView.addSubview(self.hostingController.view)
            self.makefullScreen(of: self.hostingController.view, to: self.scrollView)
            self.hostingController.didMove(toParent: self)
    
            view.addSubview(pageControl)
            pageControl.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50).isActive = true
            pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
            pageControl.heightAnchor.constraint(equalToConstant: 60).isActive = true
            pageControl.widthAnchor.constraint(equalToConstant: 200).isActive = true
        }
    
        func makefullScreen(of viewA: UIView, to viewB: UIView) {
            viewA.translatesAutoresizingMaskIntoConstraints = false
            viewB.addConstraints([
                viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
                viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
                viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
                viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
            ])
        }
    
        func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    
            let currentIndexHorizontal = round(scrollView.contentOffset.x / self.view.frame.size.width)
            let currentIndexVertical = round(scrollView.contentOffset.y / self.view.frame.size.height)
    
            switch axis {
            case .horizontal:
                self.pageControl.currentPage = Int(currentIndexHorizontal)
                break
            case .vertical:
                self.pageControl.currentPage = Int(currentIndexVertical)
                break
            default:
                break
            }
    
        }
    
    }