-
-
Save jfuellert/67e91df63394d7c9b713419ed8e2beb7 to your computer and use it in GitHub Desktop.
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) | |
]) | |
} | |
} |
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).
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?
@lilingxi01 yes I am experiencing this as well
@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
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
hmm but NavigationLink is broken, any solution?
I think this way NavigationLink worked for me
NavigationView {
ZStack {
NavigationLink(destination: Text("Second View"), isActive: $showDetail) { EmptyView() }
ScrollableView {
LazyVStack {
....
}
.padding()
}
}
}
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:
- Remove
Equatable
conformance and the associated==
function - Remove all references to
forceRefresh
- 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:
- Add
scrollViewFactory: (() -> UIScrollView)? = nil
to theinit
onScrollableView
(with a default of nil on theinit
) - Hand this on to the
UIScrollViewController
init and store as a property - Replace
let scrollView = UIScrollView()
forlet scrollView = scrollViewFactory?() ?? UIScrollView()
inUIScrollViewController
This allows the UIScrollView instance (and additional configuration) to be provided externally if required.
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:
- Remove
Equatable
conformance and the associated==
function- Remove all references to
forceRefresh
- 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:
- Add
scrollViewFactory: (() -> UIScrollView)? = nil
to theinit
onScrollableView
(with a default of nil on theinit
)- Hand this on to the
UIScrollViewController
init and store as a property- Replace
let scrollView = UIScrollView()
forlet scrollView = scrollViewFactory?() ?? UIScrollView()
inUIScrollViewController
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?
😂 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?
@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!
if you put inside a component - Text with a lot of text, then the height is cut off, how to defeat this)
@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?
@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
It's difficult to understand why Apple doesn't allow scrolling to CGPoint, but only allows scrolling to identify ID
@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.
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