How to scroll to position UIScrollView in Wrapper for SwiftUI? - swiftui

i have a ScrollView from UIKit and use it for SwiftUI: Is there any way to make a paged ScrollView in SwiftUI?
Question: How can I scroll in the UIScrollView to a position with a button click on a button in a SwiftUI View OR what is also good for my needs to scroll to a position when first displaying the ScrollView
I tried contentOffset but this didnt work. Perhaps I've done something wrong.
ScrollViewWrapper:
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = false
v.alwaysBounceVertical = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(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),
])
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
}
}
SwiftUI usage:
struct ContentView: View{
#ObservedObject var search = SearchBar()
var body: some View{
NavigationView{
GeometryReader{geo in
UIScrollViewWrapper{ //<-----------------
VStack{
ForEach(0..<10){i in
Text("lskdfj")
}
}
.frame(width: geo.size.width)
}
.navigationBarTitle("Test")
}
}
}
}

We will first declare the offset property in the UIViewControllerRepresentable, with the propertyWrapper #Binding, because its value can be changed by the scrollview or by the parent view (the ContentView).
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
#Binding var offset: CGPoint
init(offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self.content = content
_offset = offset
}
// ....//
}
If the offset changes cause of the parent view, we must apply these changes to the scrollView in the updateUIViewController function (which is called when the state of the view changes) :
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(content())
viewController.scrollView.contentOffset = offset
}
When the offset changes because the user scrolls, we must reflect this change on our Binding. To do this we must declare a Coordinator, which will be a UIScrollViewDelegate, and modify the offset in its scrollViewDidScroll function :
class Controller: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper<Content>
init(parent: UIScrollViewWrapper<Content>) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.offset = scrollView.contentOffset
}
}
and, in struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable
func makeCoordinator() -> Controller {
return Controller(parent: self)
}
Finally, for the initial offset (this is important otherwise your starting offset will always be 0), this happens in the makeUIViewController:
you have to add these lines:
vc.view.layoutIfNeeded ()
vc.scrollView.contentOffset = offset
The final project :
import SwiftUI
struct ContentView: View {
#State private var offset: CGPoint = CGPoint(x: 0, y: 200)
let texts: [String] = (1...100).map {_ in String.random(length: Int.random(in: 6...20))}
var body: some View {
ZStack(alignment: .top) {
GeometryReader { geo in
UIScrollViewWrapper(offset: $offset) { //
VStack {
Text("Start")
.foregroundColor(.red)
ForEach(texts, id: \.self) { text in
Text(text)
}
}
.padding(.top, 40)
.frame(width: geo.size.width)
}
.navigationBarTitle("Test")
}
HStack {
Text(offset.debugDescription)
Button("add") {
offset.y += 100
}
}
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
.background(Color.white)
}
}
}
class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = false
v.alwaysBounceVertical = true
return v
}()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
pinEdges(of: scrollView, to: view)
hostingController.willMove(toParent: self)
scrollView.addSubview(hostingController.view)
pinEdges(of: hostingController.view, to: scrollView)
hostingController.didMove(toParent: self)
}
func pinEdges(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),
])
}
}
struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
#Binding var offset: CGPoint
init(offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self.content = content
_offset = offset
}
func makeCoordinator() -> Controller {
return Controller(parent: self)
}
func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.scrollView.contentInsetAdjustmentBehavior = .never
vc.hostingController.rootView = AnyView(content())
vc.view.layoutIfNeeded()
vc.scrollView.contentOffset = offset
vc.scrollView.delegate = context.coordinator
return vc
}
func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(content())
viewController.scrollView.contentOffset = offset
}
class Controller: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper<Content>
init(parent: UIScrollViewWrapper<Content>) {
self.parent = parent
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.offset = scrollView.contentOffset
}
}
}

You will need to pass a #Binding var offset: CGPoint into the UIScrollViewWrapper then when the button is clicked in your SwiftUI view, you can update the binding value which can then be used in the update method for UIViewControllerRepresentable. Another idea is to use UIViewRepresentable instead and use that with UIScrollView. Here is a helpful article doing that and setting its offset: https://www.fivestars.blog/articles/scrollview-offset/.

Related

SwiftUI UIScrollView content can be scrolled out of frame

I implemented a UIScrollView from UIKit in SwiftUI (without storyboard) because there is nothing similar in SwiftUI. It works quite well so far, but the ScrollView is not limited by the size of the content, but you can still scroll over the edge of the content. The stronger the zoom factor, the stronger this effect becomes apparent.
UIScrollView Wrapper in SwiftUI:
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
#Binding var didZoom: Bool
private var content: Content
init(didZoom: Binding<Bool>, #ViewBuilder content: () -> Content) {
_didZoom = didZoom
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
hostedView.backgroundColor = .black
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content), didZoom: $didZoom)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
#Binding var didZoom: Bool
init(hostingController: UIHostingController<Content>, didZoom: Binding<Bool>) {
self.hostingController = hostingController
_didZoom = didZoom
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
didZoom = !(scrollView.zoomScale == scrollView.minimumZoomScale)
}
}
}
SwiftUI ContentView:
struct ContentView: View {
var body: some View {
ZoomableScrollView {
Rectangle()
.frame(width: 420, height: 600)
}
}
}
You're making whole screen zoomable if you want to make only rectangle zoomable then only wrap that rectangle within zoomablescrollview
struct ContentView: View {
var body: some View {
ZStack{
ZoomableScrollView {
Rectangle()
.frame(width: 420, height: 600)
}.frame(width: 420,height: 600)
}
}
}

Simultaneous Gestures not working for a ZoomableScrollView in SwiftUI

Using Swift5.3.2, iOS14.4.1, Xcode12.4,
I am trying to use the .simultaneousGesture modifier in SwiftUI.
As far as I understood, this modifier should make sure that gestures (such as tap, longpress, magnification etc) should be able to co-exist within a View.
In my example I am using a ZoomableScrollView. And it works fine as long as I do not use the simultaneousGesture.
But as soon as I use the extra simultaneousGesture, the ZoomableScrollView is no longer "zoomable" (i.e. none of its gestures work anymore).
What can I do to make the zoom still work AND get an extra dragGesture ?
import SwiftUI
struct MediaTabView: View {
#GestureState private var dragOffset: CGFloat = -100
var body: some View {
ZoomableScrollView {
Image(uiImage: UIImage(contentsOfFile: url.path)!)
.resizable()
.scaledToFit()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.simultaneousGesture(
DragGesture()
.updating($dragOffset) { (value, gestureState, transaction) in
let delta = value.location.x - value.startLocation.x
if delta > 10 { // << some appropriate horizontal threshold here
gestureState = delta
print(delta)
}
}
.onEnded {
if $0.translation.width > 100 {
// Go to the previous slide
print("on ended")
}
}
)
}
}
The code for the ZoomableScrollView is here :
import SwiftUI
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
hostedView.backgroundColor = .black
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
}
}

How to create synced ScrollViews in SwiftUI

I am trying to create two synced ScrollViews in SwiftUI such that scrolling in one will result in the same scrolling in the other.
I am using the ScrollViewOffset class shown at the bottom for getting a scrollView offset value but having trouble figuring out how to scroll the other view.
I seem to be able to 'hack' it by preventing scrolling in one view and setting the content position() on the other - is there any way to actually scroll the scrollView content to a position - I know ScrollViewReader seems to allow scrolling to display content items but I can't seem to find anything that will scroll the contents to an offset position.
The problem with using position() is that it does not actually change the ScrollViews scroller positions - there seems to be no ScrollView.scrollContentsTo(point: CGPoint).
#State private var scrollOffset1: CGPoint = .zero
HStack {
ScrollViewOffset(onOffsetChange: { offset in
scrollOffset1 = offset
print("New ScrollView1 offset: \(offset)")
}, content: {
VStack {
ImageView(filteredImageProvider: self.provider)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
})
ScrollView([]) {
VStack {
ImageView(filteredImageProvider: self.provider, showEdits: false)
.frame(width: imageWidth, height: imageHeight)
}
.frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
.border(Color.white)
.id(0)
.position(x: scrollOffset1.x, y: scrollOffset1.y + (imageHeight + (geometry.size.height - 20) * 2)/2)
}
}
//
// ScrollViewOffset.swift
// ZoomView
//
//
import Foundation
import SwiftUI
struct ScrollViewOffset<Content: View>: View {
let onOffsetChange: (CGPoint) -> Void
let content: () -> Content
init(
onOffsetChange: #escaping (CGPoint) -> Void,
#ViewBuilder content: #escaping () -> Content
) {
self.onOffsetChange = onOffsetChange
self.content = content
}
var body: some View {
ScrollView([.horizontal, .vertical]) {
offsetReader
content()
.padding(.top, -8)
}
.coordinateSpace(name: "frameLayer")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: onOffsetChange)
}
var offsetReader: some View {
GeometryReader { proxy in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: proxy.frame(in: .named("frameLayer")).origin
)
}
.frame(width: 0, height: 0)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
Synced scroll views.
In this example, you can scroll the LHS scrollview and the RHS scrollview will be synchronised to the same position. In this example, the scrollview on the RHS is disabled, and the position is simply synchronised by using an offset.
But using the same logic and code, you can make both the LHS and RHS scrollviews synced when either of them are scrolled.
import SwiftUI
struct ContentView: View {
#State private var offset = CGFloat.zero
var body: some View {
HStack(alignment: .top) {
// MainScrollView
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.background( GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: .named("scroll")).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self) { value in
print("offset >> \(value)")
offset = value
}
}
.coordinateSpace(name: "scroll")
// Synchronised with ScrollView above
ScrollView {
VStack {
ForEach(0..<100) { i in
Text("Item \(i)").padding()
}
}
.offset(y: -offset)
}
.disabled(true)
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
With the current API of ScrollView, this is not possible. While you can get the contentOffset of the scrollView using methods that are widely available on the internet, the ScrollViewReader that is used to programmatically scroll a ScrollView only allows you to scroll to specific views, instead of to a contentOffset.
To achieve this functionality, you are going to have to wrap UIScrollView. Here is an implementation, although it isn't 100% stable, and is missing a good amount of scrollView functionality:
import SwiftUI
import UIKit
public struct ScrollableView<Content: View>: UIViewControllerRepresentable {
#Binding var offset: CGPoint
var content: () -> Content
public init(_ offset: Binding<CGPoint>, #ViewBuilder content: #escaping () -> Content) {
self._offset = offset
self.content = content
}
public func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.hostingController.rootView = AnyView(self.content())
vc.scrollView.setContentOffset(offset, animated: false)
vc.delegate = context.coordinator
return vc
}
public func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(self.content())
// Allow for deaceleration to be done by the scrollView
if !viewController.scrollView.isDecelerating {
viewController.scrollView.setContentOffset(offset, animated: false)
}
}
public func makeCoordinator() -> Coordinator {
Coordinator(contentOffset: _offset)
}
public class Coordinator: NSObject, UIScrollViewDelegate {
let contentOffset: Binding<CGPoint>
init(contentOffset: Binding<CGPoint>) {
self.contentOffset = contentOffset
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffset.wrappedValue = scrollView.contentOffset
}
}
}
public class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = UIScrollView()
var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))
weak var delegate: UIScrollViewDelegate?
public override func viewDidLoad() {
super.viewDidLoad()
self.scrollView.delegate = delegate
self.view.addSubview(self.scrollView)
self.pinEdges(of: self.scrollView, to: self.view)
self.hostingController.willMove(toParent: self)
self.scrollView.addSubview(self.hostingController.view)
self.pinEdges(of: self.hostingController.view, to: self.scrollView)
self.hostingController.didMove(toParent: self)
}
func pinEdges(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),
])
}
}
struct ScrollableView_Previews: PreviewProvider {
static var previews: some View {
Wrapper()
}
struct Wrapper: View {
#State var offset: CGPoint = .init(x: 0, y: 50)
var body: some View {
HStack {
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
ScrollableView($offset, content: {
ForEach(0...100, id: \.self) { id in
Text("\(id)")
}
})
VStack {
Text("x: \(offset.x) y: \(offset.y)")
Button("Top", action: {
offset = .zero
})
.buttonStyle(.borderedProminent)
}
.frame(width: 200)
.padding()
}
}
}
}

SwiftUI SearchBar problem with NavigationLink

I've a problem in SwiftUI with the searchBar appear.
There's a delay on its appear when I use NavigationLink. I saw that the problem appears only with NavigationLinks, if I use a conditional overlay or others "handmade" way to move between Views the problem doesn't appear. You know what I could do to fix the problem?
Here's my views code:
import SwiftUI
struct ContentView: View {
#State var searchText = ""
var body: some View {
NavigationView{
NavigationLink(destination: ContentView2()){
Text("Go to Sub View")
}
.navigationBarTitle("Main View")
.add(SearchBar(text: self.$searchText, hide: true, placeholder: "Search", cancelButton: true, autocapitalization: .sentences))
}
}
}
struct ContentView2 : View {
#State var searchText = ""
var body: some View {
Text("Hello, world!")
.navigationBarTitle("Sub View")
.add(SearchBar(text: self.$searchText, hide: true, placeholder: "Search", cancelButton: true, autocapitalization: .sentences))
}
}
My SearchBar code
import SwiftUI
class SearchBar: NSObject, ObservableObject {
let searchController: UISearchController = UISearchController(searchResultsController: nil)
#Binding var text: String
let hide : Bool
let placeholder : String
let cancelButton : Bool
let autocapitalization : UITextAutocapitalizationType
init(text: Binding<String>, hide: Bool, placeholder: String, cancelButton: Bool, autocapitalization: UITextAutocapitalizationType) {
self._text = text
self.hide = hide
self.placeholder = placeholder
self.cancelButton = cancelButton
self.autocapitalization = autocapitalization
super.init()
self.searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchResultsUpdater = self
self.searchController.hidesNavigationBarDuringPresentation = hide
self.searchController.automaticallyShowsCancelButton = cancelButton
self.searchController.searchBar.placeholder = placeholder
self.searchController.searchBar.autocapitalizationType = autocapitalization
}
}
extension SearchBar: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
// Publish search bar text changes.
if let searchBarText = searchController.searchBar.text {
self.text = searchBarText
}
}
}
struct SearchBarModifier: ViewModifier {
let searchBar: SearchBar
func body(content: Content) -> some View {
content
.overlay(
ViewControllerResolver { viewController in
viewController.navigationItem.searchController = self.searchBar.searchController
}
.frame(width: 0, height: 0)
)
}
}
extension View {
func add(_ searchBar: SearchBar) -> some View {
return self.modifier(SearchBarModifier(searchBar: searchBar))
}
}
My ViewController code
import SwiftUI
final class ViewControllerResolver: UIViewControllerRepresentable {
let onResolve: (UIViewController) -> Void
init(onResolve: #escaping (UIViewController) -> Void) {
self.onResolve = onResolve
}
func makeUIViewController(context: Context) -> ParentResolverViewController {
ParentResolverViewController(onResolve: onResolve)
}
func updateUIViewController(_ uiViewController: ParentResolverViewController, context: Context) { }
}
class ParentResolverViewController: UIViewController {
let onResolve: (UIViewController) -> Void
init(onResolve: #escaping (UIViewController) -> Void) {
self.onResolve = onResolve
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("Use init(onResolve:) to instantiate ParentResolverViewController.")
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent {
onResolve(parent)
}
}
override func viewDidAppear(_ animated: Bool) {
self.parent?.navigationItem.hidesSearchBarWhenScrolling = false
self.parent?.definesPresentationContext = true
self.parent?.navigationController?.navigationBar.sizeToFit()
}
override func viewDidDisappear(_ animated: Bool) {
self.parent?.navigationItem.hidesSearchBarWhenScrolling = false
self.parent?.definesPresentationContext = true
self.parent?.navigationController?.navigationBar.sizeToFit()
}
}
And here's a video of the problem
Set the hidesSearchBarWhenScrolling property before the SearchBar is displayed on the screen. This can be done in viewWillAppear or as in the example below:
struct SearchBarModifier: ViewModifier {
let searchBar: SearchBar
func body(content: Content) -> some View {
content
.overlay(
ViewControllerResolver { viewController in
viewController.navigationItem.searchController = self.searchBar.searchController
viewController.navigationItem.hidesSearchBarWhenScrolling = false
}
.frame(width: 0, height: 0)
)
}
}

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