-
-
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) | |
]) | |
} | |
} |
Updated!
This fixed the update issue for me, thanks for the speedy response times
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 !
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 ?
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.
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.
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.
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?
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?
My app is so badly put together at present that I cannot tell you this.
NavigationLink is not working when clicking on the cell of a view.
did this ever get resolved? @arbnori45
Hi, it seems like it does not support NavigationLink. Is there a workaround?
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
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.
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...