Last active
April 11, 2024 17:57
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
]) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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.