Created
April 8, 2022 11:31
-
-
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.
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 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