-
-
Save drinkius/a9ba0b101721a578ca6323b2785538b4 to your computer and use it in GitHub Desktop.
A UIScrollView but SwiftUI
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 ContentView: View { | |
@State private var bounds: Bounds = .zero | |
var body: some View { | |
VStack { | |
controls | |
scrollArea | |
} | |
} | |
private var controls: some View { | |
HStack { | |
Text(verbatim: "\(bounds)") | |
Spacer() | |
Button("-100") { | |
withAnimation { | |
self.bounds.origin.y -= 100 | |
} | |
} | |
} | |
} | |
private var scrollArea: some View { | |
ScrollArea(bounds: $bounds) { | |
VStack { | |
ForEach(0..<50) { item in | |
self.row(for: item) | |
} | |
} | |
} | |
} | |
private func row(for item: Int) -> some View { | |
HStack { | |
Text("Item: \(item)") | |
.padding([.leading, .trailing]) | |
.background(Color.blue) | |
Spacer() | |
} | |
} | |
} |
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 Bounds: Equatable { | |
static let zero: Bounds = .init() | |
var origin: CGPoint | |
var size: CGSize? | |
init(x: CGFloat = 0, y: CGFloat = 0, size: CGSize? = nil) { | |
self.origin = CGPoint(x: x, y: y) | |
self.size = size | |
} | |
} | |
struct ScrollArea<Content>: View where Content: View { | |
@Binding private var bounds: Bounds | |
private let content: () -> Content | |
init(bounds: Binding<Bounds>, @ViewBuilder content: @escaping () -> Content) { | |
self._bounds = bounds | |
self.content = content | |
} | |
var body: some View { | |
UIScrollViewRepresentation(origin: $bounds.origin, contentSize: bounds.size) { | |
ZStack { | |
self.content() | |
.background(self.reportSize) | |
} | |
.frame(minHeight: 0, maxHeight: .infinity) | |
.onPreferenceChange(ScrollAreaContentSizePreference.self) { | |
if let newValue = $0, self.bounds.size != newValue { | |
self.bounds.size = newValue | |
} | |
} | |
} | |
} | |
private var reportSize: some View { | |
GeometryReader { proxy in | |
Color.clear | |
.preference(key: ScrollAreaContentSizePreference.self, value: proxy.size) | |
} | |
} | |
} | |
private struct ScrollAreaContentSizePreference: PreferenceKey { | |
typealias Value = CGSize? | |
static var defaultValue: Value = nil | |
static func reduce(value: inout Value, nextValue: () -> Value) { | |
if let next = nextValue() { | |
value = next | |
} | |
} | |
} |
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 | |
import UIKit | |
struct UIScrollViewRepresentation<Content>: UIViewControllerRepresentable where Content: View { | |
typealias UIViewControllerType = UIScrollViewController<Content> | |
typealias Coordinator = UIScrollViewCoordinator | |
@Binding private var origin: CGPoint | |
private let contentSize: CGSize? | |
private let content: () -> Content | |
init(origin: Binding<CGPoint>, contentSize: CGSize? = nil, @ViewBuilder content: @escaping () -> Content) { | |
self._origin = origin | |
self.contentSize = contentSize | |
self.content = content | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(origin: $origin) | |
} | |
func makeUIViewController(context: Context) -> UIViewControllerType { | |
let controller = UIViewControllerType(content: content) | |
controller.scrollView.delegate = context.coordinator | |
controller.scrollView.alwaysBounceVertical = true | |
return controller | |
} | |
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { | |
if let newValue = contentSize { | |
uiViewController.scrollView.contentSize = newValue | |
} | |
if origin != uiViewController.scrollView.contentOffset { | |
context.coordinator.willUpdateContentOffset() | |
if context.transaction.animation != nil { | |
uiViewController.scrollView.setContentOffset(origin, animated: true) | |
} else if context.transaction.disablesAnimations { | |
UIView.performWithoutAnimation { | |
uiViewController.scrollView.contentOffset = origin | |
} | |
} else { | |
uiViewController.scrollView.contentOffset = origin | |
} | |
} | |
} | |
} | |
class UIScrollViewCoordinator: NSObject, UIScrollViewDelegate { | |
@Binding private var origin: CGPoint | |
private var isUpdatingContentOffset: Bool = false | |
fileprivate init(origin: Binding<CGPoint>) { | |
self._origin = origin | |
} | |
fileprivate func willUpdateContentOffset() { | |
isUpdatingContentOffset = true | |
} | |
func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
if scrollView.contentOffset != origin { | |
if isUpdatingContentOffset { | |
return isUpdatingContentOffset = false | |
} | |
origin = scrollView.contentOffset | |
} | |
} | |
} | |
class UIScrollViewController<Content>: UIViewController where Content: View { | |
let content: () -> Content | |
private(set) lazy var scrollView = UIScrollView() | |
private lazy var host = UIHostingController<Content>(rootView: content()) | |
init(@ViewBuilder content: @escaping () -> Content) { | |
self.content = content | |
super.init(nibName: nil, bundle: nil) | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
setupScrollView() | |
setupHostView() | |
} | |
private func setupScrollView() { | |
view.addSubview(scrollView) | |
scrollView.translatesAutoresizingMaskIntoConstraints = false | |
let guide = scrollView.frameLayoutGuide | |
NSLayoutConstraint.activate([ | |
guide.topAnchor.constraint(equalTo: view.topAnchor), | |
guide.leadingAnchor.constraint(equalTo: view.leadingAnchor), | |
guide.trailingAnchor.constraint(equalTo: view.trailingAnchor), | |
guide.bottomAnchor.constraint(equalTo: view.bottomAnchor) | |
]) | |
} | |
private func setupHostView() { | |
addChild(host) | |
scrollView.addSubview(host.view) | |
host.didMove(toParent: self) | |
host.view.translatesAutoresizingMaskIntoConstraints = false | |
let guide = scrollView.contentLayoutGuide | |
NSLayoutConstraint.activate([ | |
host.view.topAnchor.constraint(equalTo: guide.topAnchor), | |
host.view.leadingAnchor.constraint(equalTo: guide.leadingAnchor), | |
host.view.trailingAnchor.constraint(equalTo: guide.trailingAnchor), | |
host.view.bottomAnchor.constraint(equalTo: guide.bottomAnchor), | |
guide.widthAnchor.constraint(equalTo: view.widthAnchor) | |
]) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment