Created
March 5, 2020 21:20
-
-
Save marcpalmer/42e10f6baf3275115ebb37c707cd99a6 to your computer and use it in GitHub Desktop.
Work in progress UIScrollView-alike in 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
// | |
// 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