12

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

fatihyildizhan
  • 8,614
  • 7
  • 64
  • 88
Osama Naeem
  • 1,830
  • 5
  • 16
  • 34
  • 1
    If you can, try wrapping the NavigationLink inside a `VStack`. There are similar cases where this solves the problem. Good luck! – kontiki Aug 14 '19 at 19:39
  • Hey, I have tried that. NavigationLink is already inside a VStack. I tried putting the navigationView inside the View which I am showing in custom scrollView wrapper and that seems to have made the navigationLink responsive. However, it pushes the view wrongly. Let me update the question, so you can have idea how I am using this. – Osama Naeem Aug 14 '19 at 19:43
  • @kontiki I have updated the question. Kindly take a look! Thanks. – Osama Naeem Aug 14 '19 at 19:46

3 Answers3

18

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
        }

    }

}
kontiki
  • 37,663
  • 13
  • 111
  • 125
0

The above solution works if we are not required to navigate to different screens from the content of scroll view. However, if we need a navigation link onto the scroll content instead of the scroll view itself, then the below code would work perfectly.

I was into a similar problem. I have figured out that the problem is with the UIViewControllerRepresentable. Instead use UIViewRepresentable, although I am not sure what the issue is. I was able to get the navigationlink work using the below code.

struct SwiftyUIScrollView<Content>: UIViewRepresentable where Content: View {
typealias UIViewType = Scroll

var content: () -> Content
var pagingEnabled: Bool = false
var hideScrollIndicators: Bool = false
@Binding var shouldUpdate: Bool
@Binding var currentIndex: Int

var onScrollIndexChanged: ((_ index: Int) -> Void)

public init(pagingEnabled: Bool,
            hideScrollIndicators: Bool,
            currentIndex: Binding<Int>,
            shouldUpdate: Binding<Bool>,
            @ViewBuilder content: @escaping () -> Content, onScrollIndexChanged: @escaping ((_ index: Int) -> Void)) {
    self.content = content
    self.pagingEnabled = pagingEnabled
    self._currentIndex = currentIndex
    self._shouldUpdate = shouldUpdate
    self.hideScrollIndicators = hideScrollIndicators
    self.onScrollIndexChanged = onScrollIndexChanged
}

func makeUIView(context: UIViewRepresentableContext<SwiftyUIScrollView>) -> UIViewType {
    let hosting = UIHostingController(rootView: content())
    let view = Scroll(hideScrollIndicators: hideScrollIndicators, isPagingEnabled: pagingEnabled)
    view.scrollDelegate = context.coordinator
    view.alwaysBounceHorizontal = true
    view.addSubview(hosting.view)
    makefullScreen(of: hosting.view, to: view)
    return view
}

class Coordinator: NSObject, ScrollViewDelegate {
    func didScrollToIndex(_ index: Int) {
        self.parent.onScrollIndexChanged(index)
    }

    var parent: SwiftyUIScrollView

    init(_ parent: SwiftyUIScrollView) {
        self.parent = parent
    }
}

func makeCoordinator() -> SwiftyUIScrollView<Content>.Coordinator {
    Coordinator(self)
}

func updateUIView(_ uiView: Scroll, context: UIViewRepresentableContext<SwiftyUIScrollView<Content>>) {
    if shouldUpdate {
        uiView.scrollToIndex(index: currentIndex)
    }
}

func makefullScreen(of childView: UIView, to parentView: UIView) {
    childView.translatesAutoresizingMaskIntoConstraints = false
    childView.leftAnchor.constraint(equalTo: parentView.leftAnchor).isActive = true
    childView.rightAnchor.constraint(equalTo: parentView.rightAnchor).isActive = true
    childView.topAnchor.constraint(equalTo: parentView.topAnchor).isActive = true
    childView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor).isActive = true
}
}

Then create a new class to handle the delegates of a scrollview. You can include the below code into the UIViewRepresentable as well. But I prefer keeping it separated for a clean code.

class Scroll: UIScrollView, UIScrollViewDelegate {

var hideScrollIndicators: Bool = false
var scrollDelegate: ScrollViewDelegate?
var tileWidth = 270
var tileMargin = 20

init(hideScrollIndicators: Bool, isPagingEnabled: Bool) {
    super.init(frame: CGRect.zero)
    showsVerticalScrollIndicator = !hideScrollIndicators
    showsHorizontalScrollIndicator = !hideScrollIndicators
    delegate = self
    self.isPagingEnabled = isPagingEnabled
}

required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    let currentIndex = scrollView.contentOffset.x / CGFloat(tileWidth+tileMargin)
    scrollDelegate?.didScrollToIndex(Int(currentIndex))
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let currentIndex = scrollView.contentOffset.x / CGFloat(tileWidth+tileMargin)
    scrollDelegate?.didScrollToIndex(Int(currentIndex))
}

func scrollToIndex(index: Int) {
    let newOffSet = CGFloat(tileWidth+tileMargin) * CGFloat(index)
    contentOffset = CGPoint(x: newOffSet, y: contentOffset.y)
}
}

Now to implement the scrollView use the below code.

@State private var activePageIndex: Int = 0
@State private var shouldUpdateScroll: Bool = false

SwiftyUIScrollView(pagingEnabled: false, hideScrollIndicators: true, currentIndex: $activePageIndex, shouldUpdate: $shouldUpdateScroll, content: {
            HStack(spacing: 20) {
                ForEach(self.data, id: \.id) { data in
                    NavigationLink(destination: self.getTheNextView(data: data)) {
                        self.cardView(data: data)
                    }
                }
            }
            .padding(.horizontal, 30.0)
        }, onScrollIndexChanged: { (newIndex) in
           shouldUpdateScroll = false
           activePageIndex = index
            // Your own required handling
        })


func getTheNextView(data: Any) -> AnyView {
    // Return the required destination View
}
Amrit Sidhu
  • 1,870
  • 1
  • 18
  • 32
0

I had this same issue and tried lots of different solutions. The navigation link had been working and stopped. putting the view inside a navigation view worked.

In the example, masterview() contains the navigation links that did not work and now do.

struct ContentView: View {

    var body: some View {
        NavigationView {
            MasterView()
            
            //SettingsView()
            //DetailView()
            //newviewcontroller()
        }.navigationViewStyle(DoubleColumnNavigationViewStyle())
    }
}
fatihyildizhan
  • 8,614
  • 7
  • 64
  • 88
Rick
  • 21
  • 3