Skip to content

Instantly share code, notes, and snippets.

@shawnthroop
Last active May 12, 2020 22:13
Show Gist options
  • Save shawnthroop/cc36b3ca0d856cab11323778af4fc816 to your computer and use it in GitHub Desktop.
Save shawnthroop/cc36b3ca0d856cab11323778af4fc816 to your computer and use it in GitHub Desktop.
A UIScrollView but SwiftUI
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()
}
}
}
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
}
}
}
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