Skip to content

Instantly share code, notes, and snippets.

@jfuellert
Last active April 11, 2024 17:57
Show Gist options
  • Star 74 You must be signed in to star a gist
  • Fork 11 You must be signed in to fork a gist
  • Save jfuellert/67e91df63394d7c9b713419ed8e2beb7 to your computer and use it in GitHub Desktop.
Save jfuellert/67e91df63394d7c9b713419ed8e2beb7 to your computer and use it in GitHub Desktop.
A scrollable SwiftUI view, UIScrollView wrapper. ScrollableView lets you read and write content offsets for scrollview in SwiftUI, with and without animations.
import SwiftUI
struct ScrollableView<Content: View>: UIViewControllerRepresentable, Equatable {
// MARK: - Coordinator
final class Coordinator: NSObject, UIScrollViewDelegate {
// MARK: - Properties
private let scrollView: UIScrollView
var offset: Binding<CGPoint>
// MARK: - Init
init(_ scrollView: UIScrollView, offset: Binding<CGPoint>) {
self.scrollView = scrollView
self.offset = offset
super.init()
self.scrollView.delegate = self
}
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
DispatchQueue.main.async {
self.offset.wrappedValue = scrollView.contentOffset
}
}
}
// MARK: - Type
typealias UIViewControllerType = UIScrollViewController<Content>
// MARK: - Properties
var offset: Binding<CGPoint>
var animationDuration: TimeInterval
var showsScrollIndicator: Bool
var axis: Axis
var content: () -> Content
var onScale: ((CGFloat)->Void)?
var disableScroll: Bool
var forceRefresh: Bool
var stopScrolling: Binding<Bool>
private let scrollViewController: UIViewControllerType
// MARK: - Init
init(_ offset: Binding<CGPoint>, animationDuration: TimeInterval, showsScrollIndicator: Bool = true, axis: Axis = .vertical, onScale: ((CGFloat)->Void)? = nil, disableScroll: Bool = false, forceRefresh: Bool = false, stopScrolling: Binding<Bool> = .constant(false), @ViewBuilder content: @escaping () -> Content) {
self.offset = offset
self.onScale = onScale
self.animationDuration = animationDuration
self.content = content
self.showsScrollIndicator = showsScrollIndicator
self.axis = axis
self.disableScroll = disableScroll
self.forceRefresh = forceRefresh
self.stopScrolling = stopScrolling
self.scrollViewController = UIScrollViewController(rootView: self.content(), offset: self.offset, axis: self.axis, onScale: self.onScale)
}
// MARK: - Updates
func makeUIViewController(context: UIViewControllerRepresentableContext<Self>) -> UIViewControllerType {
self.scrollViewController
}
func updateUIViewController(_ viewController: UIViewControllerType, context: UIViewControllerRepresentableContext<Self>) {
viewController.scrollView.showsVerticalScrollIndicator = self.showsScrollIndicator
viewController.scrollView.showsHorizontalScrollIndicator = self.showsScrollIndicator
viewController.updateContent(self.content)
let duration: TimeInterval = self.duration(viewController)
let newValue: CGPoint = self.offset.wrappedValue
viewController.scrollView.isScrollEnabled = !self.disableScroll
if self.stopScrolling.wrappedValue {
viewController.scrollView.setContentOffset(viewController.scrollView.contentOffset, animated:false)
return
}
guard duration != .zero else {
viewController.scrollView.contentOffset = newValue
return
}
UIView.animate(withDuration: duration, delay: 0, options: [.allowUserInteraction, .curveEaseInOut, .beginFromCurrentState], animations: {
viewController.scrollView.contentOffset = newValue
}, completion: nil)
}
func makeCoordinator() -> Coordinator {
Coordinator(self.scrollViewController.scrollView, offset: self.offset)
}
//Calcaulte max offset
private func newContentOffset(_ viewController: UIViewControllerType, newValue: CGPoint) -> CGPoint {
let maxOffsetViewFrame: CGRect = viewController.view.frame
let maxOffsetFrame: CGRect = viewController.hostingController.view.frame
let maxOffsetX: CGFloat = maxOffsetFrame.maxX - maxOffsetViewFrame.maxX
let maxOffsetY: CGFloat = maxOffsetFrame.maxY - maxOffsetViewFrame.maxY
return CGPoint(x: min(newValue.x, maxOffsetX), y: min(newValue.y, maxOffsetY))
}
//Calculate animation speed
private func duration(_ viewController: UIViewControllerType) -> TimeInterval {
var diff: CGFloat = 0
switch axis {
case .horizontal:
diff = abs(viewController.scrollView.contentOffset.x - self.offset.wrappedValue.x)
default:
diff = abs(viewController.scrollView.contentOffset.y - self.offset.wrappedValue.y)
}
if diff == 0 {
return .zero
}
let percentageMoved = diff / UIScreen.main.bounds.height
return self.animationDuration * min(max(TimeInterval(percentageMoved), 0.25), 1)
}
// MARK: - Equatable
static func == (lhs: ScrollableView, rhs: ScrollableView) -> Bool {
return !lhs.forceRefresh && lhs.forceRefresh == rhs.forceRefresh
}
}
final class UIScrollViewController<Content: View> : UIViewController, ObservableObject {
// MARK: - Properties
var offset: Binding<CGPoint>
var onScale: ((CGFloat)->Void)?
let hostingController: UIHostingController<Content>
private let axis: Axis
lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.canCancelContentTouches = true
scrollView.delaysContentTouches = true
scrollView.scrollsToTop = false
scrollView.backgroundColor = .clear
if self.onScale != nil {
scrollView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(self.onGesture)))
}
return scrollView
}()
@objc func onGesture(gesture: UIPinchGestureRecognizer) {
self.onScale?(gesture.scale)
}
// MARK: - Init
init(rootView: Content, offset: Binding<CGPoint>, axis: Axis, onScale: ((CGFloat)->Void)?) {
self.offset = offset
self.hostingController = UIHostingController<Content>(rootView: rootView)
self.hostingController.view.backgroundColor = .clear
self.axis = axis
self.onScale = onScale
super.init(nibName: nil, bundle: nil)
}
// MARK: - Update
func updateContent(_ content: () -> Content) {
self.hostingController.rootView = content()
self.scrollView.addSubview(self.hostingController.view)
var contentSize: CGSize = self.hostingController.view.intrinsicContentSize
switch axis {
case .vertical:
contentSize.width = self.scrollView.frame.width
case .horizontal:
contentSize.height = self.scrollView.frame.height
}
self.hostingController.view.frame.size = contentSize
self.scrollView.contentSize = contentSize
self.view.updateConstraintsIfNeeded()
self.view.layoutIfNeeded()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(self.scrollView)
self.createConstraints()
self.view.setNeedsUpdateConstraints()
self.view.updateConstraintsIfNeeded()
self.view.layoutIfNeeded()
}
// MARK: - Constraints
fileprivate func createConstraints() {
NSLayoutConstraint.activate([
self.scrollView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor),
self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
])
}
}
@timtraver
Copy link

Actually, the initial screen being offeset horizontally was fixed for me by putting self.view.layoutIfNeeded() in the viewDidLoad. If you only put it in the updateContent, then it only happens after you touch the scrollable area...

@jfuellert
Copy link
Author

Updated!

@mlm249
Copy link

mlm249 commented Jun 1, 2020

This fixed the update issue for me, thanks for the speedy response times

@captainhaddockfr35
Copy link

Hi !
I would like to have a listener on the ScrollableView to detect when the user scrolls and to know the value of the offset. I was thinking of using the preferences and calling it from the scrollViewDidScroll but I'm not sure if this is possible. Do you have an opinion on the matter? Thank you in advance !

@halilyuce
Copy link

halilyuce commented Jul 5, 2020

Thanks @jfuellert but I've got a problem about and I read all comments, I'm not only a person who facing this issue. I'm trying to use a horizontal scroll but it does not seem before touch on it. Interesting bug, is there any solution for that ?

@jfuellert
Copy link
Author

No solution hard for that one yet, though it depends on your implementation of the scroll view itself.
I'm using horizontal scroll in two places, but both of which I'm forcing a width (infinity) and pre-setting a scroll offset to zero.

@Mr-Perfection
Copy link

Meh, I guess most ppl got this working... I'm getting this error

extensions of generic classes cannot contain '@objc' members

----------------------------------------

CompileDylibError: Failed to build ScrollableView.swift

Compiling failed: extensions of generic classes cannot contain '@objc' members

/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:50:59: error: extensions of generic classes cannot contain '@objc' members
    @_dynamicReplacement(for: viewDidLoad()) private func __preview__viewDidLoad() {
                                                          ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:50:6: error: 'viewDidLoad()' is marked @objc dynamic
    @_dynamicReplacement(for: viewDidLoad()) private func __preview__viewDidLoad() {
     ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:87:65: error: extensions of generic classes cannot contain '@objc' members
    @_dynamicReplacement(for: onGesture(gesture:)) private func __preview__onGesture(gesture: UIPinchGestureRecognizer) {
                                                                ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:87:6: error: 'onGesture(gesture:)' is marked @objc dynamic
    @_dynamicReplacement(for: onGesture(gesture:)) private func __preview__onGesture(gesture: UIPinchGestureRecognizer) {
     ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:178:40: error: ambiguous type name 'Coordinator' in 'ScrollableView'
typealias Coordinator = ScrollableView.Coordinator
                        ~~~~~~~~~~~~~~ ^
/Users/stephenlee/Developer/techat/tennis chat app/Views/Components/ScrollableView.swift:14:17: note: found candidate with type 'ScrollableView.Coordinator'
    final class Coordinator: NSObject, UIScrollViewDelegate {
                ^
SwiftUI.UIViewControllerRepresentable:9:20: note: found candidate with type 'Self.Coordinator'
    associatedtype Coordinator = Void
                   ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:179:49: error: ambiguous type name 'UIViewControllerType' in 'ScrollableView'
typealias UIViewControllerType = ScrollableView.UIViewControllerType
                                 ~~~~~~~~~~~~~~ ^
/Users/stephenlee/Developer/techat/tennis chat app/Views/Components/ScrollableView.swift:37:15: note: found candidate with type 'ScrollableView<Content>.UIViewControllerType'
    typealias UIViewControllerType = UIScrollViewController<Content>
              ^
SwiftUI.UIViewControllerRepresentable:5:20: note: found candidate with type 'Self.UIViewControllerType'
    associatedtype UIViewControllerType : UIViewController
                   ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:180:22: error: reference to generic type 'ScrollableView' requires arguments in <...>
private func ==(lhs: ScrollableView, rhs: ScrollableView) -> Bool {
                     ^
                                   <<#Content: View#>>
/Users/stephenlee/Developer/techat/tennis chat app/Views/Components/ScrollableView.swift:11:8: note: generic type 'ScrollableView' declared here
struct ScrollableView<Content: View>: UIViewControllerRepresentable, Equatable {
       ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:180:43: error: reference to generic type 'ScrollableView' requires arguments in <...>
private func ==(lhs: ScrollableView, rhs: ScrollableView) -> Bool {
                                          ^
                                                        <<#Content: View#>>
/Users/stephenlee/Developer/techat/tennis chat app/Views/Components/ScrollableView.swift:11:8: note: generic type 'ScrollableView' declared here
struct ScrollableView<Content: View>: UIViewControllerRepresentable, Equatable {
       ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:180:14: error: operator '==' declared in extension of 'ScrollableView.Coordinator' must be 'static'
private func ==(lhs: ScrollableView, rhs: ScrollableView) -> Bool {
             ^
static 
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:183:29: error: reference to generic type 'ScrollableView' requires arguments in <...>
private static func ==(lhs: ScrollableView, rhs: ScrollableView) -> Bool {
                            ^
                                          <<#Content: View#>>
/Users/stephenlee/Developer/techat/tennis chat app/Views/Components/ScrollableView.swift:11:8: note: generic type 'ScrollableView' declared here
struct ScrollableView<Content: View>: UIViewControllerRepresentable, Equatable {
       ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:183:50: error: reference to generic type 'ScrollableView' requires arguments in <...>
private static func ==(lhs: ScrollableView, rhs: ScrollableView) -> Bool {
                                                 ^
                                                               <<#Content: View#>>
/Users/stephenlee/Developer/techat/tennis chat app/Views/Components/ScrollableView.swift:11:8: note: generic type 'ScrollableView' declared here
struct ScrollableView<Content: View>: UIViewControllerRepresentable, Equatable {
       ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:187:69: error: extensions of classes from generic context cannot contain '@objc' members
    @_dynamicReplacement(for: scrollViewDidScroll(_:)) private func __preview__scrollViewDidScroll(_ scrollView: UIScrollView) {
                                                                    ^
/Users/stephenlee/Library/Developer/Xcode/DerivedData/tennis_chat_app-fbonerrlqshkvwgumvsmbispqsgp/Build/Intermediates.noindex/Previews/DEV tennis chat app/Intermediates.noindex/tennis chat app.build/Debug-iphonesimulator/tennis chat app dev.build/Objects-normal/x86_64/ScrollableView.2.preview-thunk.swift:187:6: error: 'scrollViewDidScroll' is marked @objc dynamic
    @_dynamicReplacement(for: scrollViewDidScroll(_:)) private func __preview__scrollViewDidScroll(_ scrollView: UIScrollView) {
     ^

my test

struct ScrollableViewTest: View {
    @State private var contentOffset: CGPoint = .zero

    var body: some View {
        ScrollableView(self.$contentOffset, animationDuration: 0.5) {

            Text("Scroll to bottom").onTapGesture {
                self.contentOffset = CGPoint(x: 0, y: 1000)
            }
            ForEach(1...50, id: \.self) { (i : Int) in
                Text("Test \(i)")
            }
            Button(action: {
                self.contentOffset = CGPoint(x: 0, y: 0)
            }) {
                Text("scroll to top")
            }
        }
    }
}

struct ScrollableView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ScrollableViewTest()
        }
    }
}

my deployment target is 13.4
I'm not sure why it's not working. Tried to debug the error messages but I have no clue.

@Mr-Perfection
Copy link

Mr-Perfection commented Jul 11, 2020

I ended up using https://github.com/Amazd/ScrollViewProxy worked like a charm. Or I think you can create your own scrollView using Introspect pkg. it only worked with static data. When I pass in @ObservedObject (dynamic data) it broke. The issue lies with defining ids before the data is loaded and data being loaded after ids are defined.

@AndAShape
Copy link

AndAShape commented Jul 20, 2020

I'm doing horizontal scroll with dynamic content. This didn't work. Then today it did. How odd. Thanks. Although I'm still getting the issue with it not rendering correctly until the content is pushed upwards plus I get the Modifying state during view update, this will cause undefined behavior. reported for scrollViewDidScroll error. But not all the time. How does it know that the content has changed?

@Mr-Perfection
Copy link

I'm doing horizontal scroll with dynamic content. This didn't work. Then today it did. How odd. Thanks. Although I'm still getting the issue with it not rendering correctly until the content is pushed upwards plus I get the Modifying state during view update, this will cause undefined behavior. reported for scrollViewDidScroll error. But not all the time. How does it know that the content has changed?

Did it work when you tested on the physical device as well?

@AndAShape
Copy link

My app is so badly put together at present that I cannot tell you this.

@yyl0
Copy link

yyl0 commented Nov 4, 2020

NavigationLink is not working when clicking on the cell of a view.

did this ever get resolved? @arbnori45

@buluoray
Copy link

Hi, it seems like it does not support NavigationLink. Is there a workaround?

@macStyle
Copy link

Is there anyway to implement this with a Tab Bar?
I would like to go to the top of the each page every time I click on the tab item

@lilingxi01
Copy link

lilingxi01 commented May 18, 2021

This ScrollableView works perfectly fine when I want to track the "contentOffset" without using the GeometryReader in SwiftUI (GeometryReader has a lag when other views are animating).

@lilingxi01
Copy link

lilingxi01 commented May 19, 2021

There is a problem in updating UIViewControllerRepresentable when content's @State is changing.

Here is a minimal demo for you to reproduce the problem:

struct ContentView: View {
    @State var offset: CGPoint = .zero
    @State var text = "abc"
    
    var body: some View {
        ScrollableView(self.$offset, animationDuration: 0.5) {
            VStack() {
                Text(text)
            }
        }
        .onAppear {
            self.text = "123"
        }
    }
}

In general, the Text should be modified to "123". But the view cannot update correctly.
And if you make a tiny scroll (either add a long Spacer or allow vertical bounce), the text will be updated.

It seems like a bug in SwiftUI's adaptor for UIView.
Any other people meet this problem?

@jfischoff
Copy link

@lilingxi01 yes I am experiencing this as well

@svenoaks
Copy link

svenoaks commented Sep 23, 2021

@lilingxi01, @jfischoff I couldn't make your exact demo cause the bug, but I can reproduce with:

struct ContentView: View {
    @State var offset: CGPoint = .zero
    @State var text = "abc"
    
    var body: some View {
        ScrollableView($text, self.$offset, animationDuration: 0.5) {
            VStack() {
                Text(text)
            }
        }
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                self.text = "123"
            }
            
        }
    }
}

One thing that fixes it is adding a binding to the changing property in the ScrollableView, ie:

let text: Binding<String>

    // MARK: - Init
    init(_ text: Binding<String>, _ offset: Binding<CGPoint>, animationDuration: TimeInterval, showsScrollIndicator: Bool = true, axis: Axis = .vertical, onScale: ((CGFloat)->Void)? = nil, disableScroll: Bool = false, forceRefresh: Bool = false, stopScrolling: Binding<Bool> = .constant(false),  @ViewBuilder content: @escaping () -> Content) {
        self.text = text

@pavm035
Copy link

pavm035 commented Oct 30, 2021

I think this is something to do with Equatable in ScrollableView if i remove that Equatable it's updating the content, but not sure if there is any other side effect, what i believe is SwiftUI trying to compare view snapshot but due to custom equability it forces that nothing has been changed

@pavm035
Copy link

pavm035 commented Oct 30, 2021

hmm but NavigationLink is broken, any solution?

@pavm035
Copy link

pavm035 commented Oct 30, 2021

I think this way NavigationLink worked for me

NavigationView {
            ZStack {
                NavigationLink(destination: Text("Second View"), isActive: $showDetail) { EmptyView() }
                ScrollableView {
                    LazyVStack {
                        ....
                    }
                    .padding()
                }
            }
        }

@lawmaestro
Copy link

Thanks for this code snippet. I had a complex gesture use-case which I wasn't able to reproduce with a SwiftUI ScrollView and the gesture support. And an issue I found and fixed. Here's what I changed, if you'd like to consider incorporating any of the changes:

Issue: Scroll view doesn't update when content changes. This can be reproduced with a simple ForEach bound to a view model
Cause: Equatable conformance - this is breaking the View diffing SwiftUI performs.
Solution:

  1. Remove Equatable conformance and the associated == function
  2. Remove all references toforceRefresh
  3. Forced view refreshing should be avoided (as it's kind of code smell as SwiftUI should be handling this for us) - however, if required then this can be achieved by for example calling objectWillChange.send() from an observed view model.

Limitation: It's not possible to influence the configuration of the UIScrollView in any way from instantiation calling site
Use-case: SwiftUI view created and embedded within a UIViewController and need to be able to interface with the UIScrollViews pan gesture as this needs to be cancelled in instances where a different gesture needs to take precedence.
Addition:

  1. Add scrollViewFactory: (() -> UIScrollView)? = nil to the init on ScrollableView (with a default of nil on the init)
  2. Hand this on to the UIScrollViewController init and store as a property
  3. Replace let scrollView = UIScrollView() for let scrollView = scrollViewFactory?() ?? UIScrollView() in UIScrollViewController

This allows the UIScrollView instance (and additional configuration) to be provided externally if required.

@Kinark
Copy link

Kinark commented Dec 8, 2022

Thanks for this code snippet. I had a complex gesture use-case which I wasn't able to reproduce with a SwiftUI ScrollView and the gesture support. And an issue I found and fixed. Here's what I changed, if you'd like to consider incorporating any of the changes:

Issue: Scroll view doesn't update when content changes. This can be reproduced with a simple ForEach bound to a view model Cause: Equatable conformance - this is breaking the View diffing SwiftUI performs. Solution:

  1. Remove Equatable conformance and the associated == function
  2. Remove all references toforceRefresh
  3. Forced view refreshing should be avoided (as it's kind of code smell as SwiftUI should be handling this for us) - however, if required then this can be achieved by for example calling objectWillChange.send() from an observed view model.

Limitation: It's not possible to influence the configuration of the UIScrollView in any way from instantiation calling site Use-case: SwiftUI view created and embedded within a UIViewController and need to be able to interface with the UIScrollViews pan gesture as this needs to be cancelled in instances where a different gesture needs to take precedence. Addition:

  1. Add scrollViewFactory: (() -> UIScrollView)? = nil to the init on ScrollableView (with a default of nil on the init)
  2. Hand this on to the UIScrollViewController init and store as a property
  3. Replace let scrollView = UIScrollView() for let scrollView = scrollViewFactory?() ?? UIScrollView() in UIScrollViewController

This allows the UIScrollView instance (and additional configuration) to be provided externally if required.

The Addition section is completely bad explained. Could you write it again?

@lawmaestro
Copy link

😂 Sorry you feel the addition section is poorly explained. I’m happy to try and clarify. What step in particular (if any) isn’t clear?

@louis1001
Copy link

@lawmaestro I was having the same problems (the SwiftUI content view should update but doesn't), and your solutions worked for me. Thanks!

Another thing I noticed is that in UIScrollViewController.updateContent, after you add the hosting controller's view, there's no UIViewController.addChild or didMove(toParent:). I don't know how much that affects this, or if it's intentional.

A different fix (hack) I applied was that it doesn't handle screen rotation nicely. The content view does change size, but it's somehow offset after rotating. The way I fixed it was separating the part in updateContent that handles content size into a updateContentSize function. Like this:

func updateContentSize() {
    var contentSize: CGSize = self.hostingController.view.intrinsicContentSize
    
    switch axis {
        case .vertical:
            contentSize.width = self.scrollView.frame.width
        case .horizontal:
            contentSize.height = self.scrollView.frame.height
    }
    
    self.hostingController.view.frame.size = contentSize
    self.scrollView.contentSize            = contentSize
}

And added a viewDidLayoutSubviews that calls it.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    updateContentSize()
    self.view.setNeedsUpdateConstraints()
    self.view.updateConstraintsIfNeeded()
}

I'm using this gist because the app I'm working on supports iOS 13. I think ScrollViewReader should work fine for a pure SwiftUI solution. Either way, it helped a lot. Thanks!

@Bagmet-Denis
Copy link

if you put inside a component - Text with a lot of text, then the height is cut off, how to defeat this)

@nuhash-bcraft
Copy link

nuhash-bcraft commented Aug 13, 2023

@jfuellert when i tried to use LazyHStack or LazyHGrid inside the Scrollable, the lazy property doesn't work with the data items. Any Idea why is that?

@jfuellert
Copy link
Author

@nuhash-bcraft Unfortunately this gist is very out of date. I'd recommend using scroll tags and ScrollViewProxy to achieve the same thing iOS 15+. If you're only supporting iOS 17 / Sonoma+ then I'd recommend using the new scroll view APIs

@hoangnam714
Copy link

It's difficult to understand why Apple doesn't allow scrolling to CGPoint, but only allows scrolling to identify ID

@nuhash-bcraft
Copy link

nuhash-bcraft commented Jan 9, 2024

@hoangnam714 I have achieved scrolling to CGPoint by using scrollTo(id:, anchor:) on SwiftUI Scrollview. I have manipulated the achor parameter by calling it repeatatively with a timer and managing id. It works good. I am supporting ios14+. But the problem is that swiftUI Scrollview does not have any api that will support if the scrollview is touched by user. If u know any way please let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment