Skip to content

Instantly share code, notes, and snippets.

@marcpalmer
Created March 5, 2020 21:20
Show Gist options
  • Save marcpalmer/42e10f6baf3275115ebb37c707cd99a6 to your computer and use it in GitHub Desktop.
Save marcpalmer/42e10f6baf3275115ebb37c707cd99a6 to your computer and use it in GitHub Desktop.
Work in progress UIScrollView-alike in SwiftUI
//
// ContentView.swift
// ExtendedScrolView
//
// Created by Marc Palmer on 05/03/2020.
// Copyright © 2020 Montana Floss Co. Ltd. All rights reserved.
//
import SwiftUI
// We use this to store the size of the content of the scroll view
struct ContentExtentPreferenceData: Equatable {
let size: CGSize
}
// This is the key for the preference that stores the content extent
fileprivate struct ContentExtentKey: PreferenceKey {
typealias Value = ContentExtentPreferenceData?
static var defaultValue: ContentExtentPreferenceData?
static func reduce(value: inout ContentExtentPreferenceData?, nextValue: () -> ContentExtentPreferenceData?) {
value = value ?? nextValue()
}
}
// We use this to store the anchor containing the frame of the content of the scroll view
// so that we can convert it to a size for the Content Extent.
struct ContentBoundsAnchorPreferenceData {
let anchor: Anchor<CGRect>
}
// This is the key for the anchor that contains the content bounds.
// We have to use this because GeometryReader gives us only what is _offered_, not what
// _consumed_ by the content of the scroll view, which is often larger than what is offered,
// that's why you have a scroll view!
fileprivate struct ContentBoundsAnchorKey: PreferenceKey {
typealias Value = ContentBoundsAnchorPreferenceData?
static var defaultValue: ContentBoundsAnchorPreferenceData?
static func reduce(value: inout ContentBoundsAnchorPreferenceData?, nextValue: () -> ContentBoundsAnchorPreferenceData?) {
value = value ?? nextValue()
}
}
// An attempt to replicate UIScrollView-ish behaviour.
//
// * You can set content offset via the `scrollOffset` binding you pass in.
// * It has basic "rubber banding"-ish support when scrolling beyond extends. We can
// add a function to reduce the offset the further you move beyond bounds to perfect that feel
//
// Note: the inertial scroll is not really there yet, it seems the predicted translation stuff
// from DragGesture.Value might not be what we hope / we need to manually track some velocity
// and extrapolate more when the drag is released.
struct ExtendedScrollView<Content: View>: View {
let scrollOffset: Binding<CGSize>
let bodyContent: () -> Content
@State private var isGestureActive: Bool = false
@State private var scrollOffsetBeforeDrag: CGSize?
@State private var contentExtent: CGSize = .zero
init(scrollOffset: Binding<CGSize>, @ViewBuilder bodyContent: @escaping () -> Content) {
self.scrollOffset = scrollOffset
self.bodyContent = bodyContent
}
var body: some View {
GeometryReader { scrollViewGeometry in
ScrollView(.vertical) {
self.bodyContent()
.background(
Color.clear
.anchorPreference(key: ContentBoundsAnchorKey.self, value: .bounds) { anchor in
return ContentBoundsAnchorPreferenceData(anchor: anchor)
}
)
}
.content.offset(y: -self.scrollOffset.wrappedValue.height)
.frame(width: nil,
height: scrollViewGeometry.size.height,
alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.backgroundPreferenceValue(ContentBoundsAnchorKey.self) { sizeData in
GeometryReader { geometry in
Color.clear
.preference(key: ContentExtentKey.self,
value: ContentExtentPreferenceData(size: geometry[sizeData!.anchor].size))
}
}
.onPreferenceChange(ContentExtentKey.self) { sizeData in
self.contentExtent = sizeData?.size ?? .zero
}
.gesture(DragGesture().onChanged({ value in
if !self.isGestureActive {
self.scrollOffsetBeforeDrag = self.scrollOffset.wrappedValue
}
self.isGestureActive = true
self.scrollOffset.wrappedValue = self.newOffset(translation: value.translation)
}).onEnded({ value in
withAnimation {
var finalOffset = self.newOffset(translation: value.predictedEndTranslation)
if finalOffset.height < 0 {
finalOffset.height = 0
}
let maxYOffset = self.contentExtent.height-scrollViewGeometry.size.height
if finalOffset.height > maxYOffset {
finalOffset.height = maxYOffset
}
self.scrollOffset.wrappedValue = finalOffset
}
DispatchQueue.main.async {
self.isGestureActive = false
}
}))
}
}
// Calculate the new offset based on the drag translation
func newOffset(translation: CGSize) -> CGSize {
guard let offsetBeforeDrag = scrollOffsetBeforeDrag else {
fatalError("Something has gone wrong with state")
}
var result = offsetBeforeDrag
result.width -= translation.width
result.height -= translation.height
return result
}
}
// Fill it with some test data.
struct TestView: View {
@State var scrollOffset: CGSize = .zero // Try for a set offset CGSize(width: 0, height: 500)
var body: some View {
ExtendedScrollView(scrollOffset: $scrollOffset) {
VStack {
ForEach((1..<10), id: \.self) { i in
Text("Hello, World! (\(i)): \(self.scrollOffset.height, specifier: "%0.2f")")
.frame(width: 300, height: 200)
.background(Color.red.hueRotation(.degrees(Double(i)*40)))
.padding()
}
}.frame(alignment: .top)
}
}
}
struct ExtendedScrollView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment