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)
])
}
}
@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