-
-
Save wildthink/9ec16f232f46579421cbf1c57e1f0b5a 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 hidden or 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