Skip to content

Instantly share code, notes, and snippets.

@Nillerr
Created April 8, 2022 11:31
Show Gist options
  • Save Nillerr/77705ee5ee42f74c1cd5c1959ba05de0 to your computer and use it in GitHub Desktop.
Save Nillerr/77705ee5ee42f74c1cd5c1959ba05de0 to your computer and use it in GitHub Desktop.
A `ScrollView` wrapper that provides the content size and offset through `@Binding`s, as well as a vertical `ScrollView` with a "scroll to bottom" button.
import Combine
import SwiftUI
/// Contains the gap between the smallest value for the y-coordinate of
/// the frame layer and the content layer.
private struct ContentOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
/// Contains the gap between the larger value for the y-coordinate of
/// the frame layer and the content layer.
private struct ContentSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct ObservableScrollView<Content: View>: View {
@Namespace private var frameLayer
@Binding var contentOffset: CGPoint
@Binding var contentSize: CGSize
let axes: Axis.Set
let showsIndicators: Bool
let content: Content
init(
_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
contentOffset: Binding<CGPoint> = .constant(.zero),
contentSize: Binding<CGSize> = .constant(.zero),
@ViewBuilder content: () -> Content
) {
_contentOffset = contentOffset
_contentSize = contentSize
self.axes = axes
self.showsIndicators = showsIndicators
self.content = content()
}
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
ZStack {
GeometryReader { geometry in
Color.clear
.preference(key: ContentOffsetPreferenceKey.self, value: geometry.frame(in: .named(frameLayer)).origin)
.preference(key: ContentSizePreferenceKey.self, value: geometry.size)
}
content
}
}
.coordinateSpace(name: frameLayer)
.onPreferenceChange(ContentOffsetPreferenceKey.self) { contentOffset = $0 }
.onPreferenceChange(ContentSizePreferenceKey.self) { contentSize = $0 }
}
}
/// Contains the gap between the larger value for the y-coordinate of
/// the frame layer and the content layer.
private struct ScrollViewSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct VerticalButtonScrollView<Content: View>: View {
@State private var contentOffset: CGPoint = .zero
@State private var contentSize: CGSize = .zero
@State private var scrollViewSize: CGSize = .zero
@State private var isIndicatorVisible: Bool = true
var slack: CGFloat = 0
@ViewBuilder let content: () -> Content
private let scrollToBottom = PassthroughSubject<Void, Never>()
var body: some View {
ZStack(alignment: .bottomTrailing) {
GeometryReader { geometry in
ScrollViewReader { scrollView in
ObservableScrollView(contentOffset: $contentOffset, contentSize: $contentSize) {
VStack(spacing: 0) {
content()
Color.clear
.frame(height: 0)
.id("bottom")
}
}
.preference(key: ScrollViewSizePreferenceKey.self, value: geometry.size)
.onReceive(scrollToBottom) { _ in
withAnimation(.interactiveSpring()) {
scrollView.scrollTo("bottom", anchor: .bottom)
}
}
}
}
.zIndex(0)
if isIndicatorVisible {
Button {
scrollToBottom.send()
} label: {
Image("icon-scroll-to-bottom")
.foregroundColor(Colors.shared.primaryColor)
.frame(width: 48, height: 48, alignment: .center)
}
.background(
Circle()
.fill(Colors.shared.background)
.sketchShadow(color: .black.opacity(0.14), x: 0, y: 6, blur: 38)
)
.padding(24)
.zIndex(1)
.transition(.scale.combined(with: .opacity))
}
}
.onPreferenceChange(ScrollViewSizePreferenceKey.self) {
scrollViewSize = $0
}
.onChange(of: contentOffset) { _ in updateIndicatorVisibility() }
.onChange(of: contentSize) { _ in updateIndicatorVisibility() }
.onChange(of: scrollViewSize) { _ in updateIndicatorVisibility() }
}
private func updateIndicatorVisibility() {
let newValue = (contentSize.height + contentOffset.y) > (scrollViewSize.height + slack)
if newValue != isIndicatorVisible {
withAnimation(.spring()) {
isIndicatorVisible = newValue
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment