Skip to content

Instantly share code, notes, and snippets.

@bennokress
Created March 15, 2022 14:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bennokress/4ca6a671de79ebb3b4e4d7ced00813d7 to your computer and use it in GitHub Desktop.
Save bennokress/4ca6a671de79ebb3b4e4d7ced00813d7 to your computer and use it in GitHub Desktop.
//
// ContentView.swift
// Component Designer
//
// Created by Benno on 15.03.22.
//
import SwiftUI
struct ContentView: View {
@State private var availableHeight: CGFloat = 0
@State private var idealHeight: CGFloat = 0
@State private var scrollViewOffset: CGPoint = .zero
private let fadePaddingSize: CGFloat = 48
private var showTopFade: Bool { scrollViewOffset.y < 0 } // withAnimation inside does not do anything
private var showBottomFade: Bool { scrollViewOffset.y > availableHeight - idealHeight } // withAnimation inside does not do anything
var body: some View {
contentLayer
.padding()
}
private var contentLayer: some View {
VStack(alignment: .leading, spacing: 0) {
titleArea
contentWrapper
buttonArea
}
}
private var titleArea: some View {
Text("Screen Title")
.font(.largeTitle)
.foregroundColor(.blue)
.padding(.bottom, 16)
}
private var contentWrapper: some View {
GeometryReader { geometry in
contentArea
.measureHeight { availableHeight = $0 }
.mask(fadingMask)
}
}
private var contentArea: some View {
OffsetScrollView(.vertical, showsIndicators: false, offset: $scrollViewOffset) {
scrollViewContent
.measureIdealHeight { idealHeight = $0 }
.frame(maxWidth: .infinity, alignment: .center)
}
}
private var scrollViewContent: some View {
VStack(alignment: .center, spacing: 16) {
ForEach(0..<100) { index in
Text(String(index))
.id(index)
}
}
}
private var buttonArea: some View {
Button("\(scrollViewOffset.y), \(idealHeight), \(availableHeight)") { print("Primary Action tapped") }
.padding()
.border(.orange, width: 2)
.frame(maxWidth: .infinity)
.padding(.top, 16)
}
// MARK: Fade Components
private var fadingMask: some View {
VStack(spacing: 0) {
LinearGradient(gradient: showTopFade ? .linearFadeIn : .invisibleMask, startPoint: .top, endPoint: .bottom)
.frame(height: fadePaddingSize) // .animation added here does not do anything
Rectangle()
.fill(Color.white)
LinearGradient(gradient: showBottomFade ? .linearFadeOut : .invisibleMask, startPoint: .top, endPoint: .bottom)
.frame(height: fadePaddingSize) // .animation added here does not do anything
}
}
}
// MARK: - CGPoint
extension CGPoint {
static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}
}
// MARK: - Gradient
extension Gradient {
private static let fadeInMaskColors = [Color.black.opacity(0), Color.black]
static let invisibleMask = Gradient(colors: [.white])
static let linearFadeIn = Gradient(colors: fadeInMaskColors)
static let linearFadeOut = Gradient(colors: fadeInMaskColors.reversed())
}
// MARK: - Measurement
// This is taken and improved upon code from an episode of objc --> https://talk.objc.io/episodes/S01E241-swiftui-layout-challenge-1
// In depth in a nearly identical approach --> https://fivestars.blog/swiftui/swiftui-share-layout-information.html
struct SizeKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
extension View {
/// Measures the size of the view inside the current surroundings.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed size into a state variable)
func measureSize(_ onMeasurementDone: @escaping (CGSize) -> Void) -> some View {
overlay(GeometryReader { Color.clear.preference(key: SizeKey.self, value: $0.size) }
.onPreferenceChange(SizeKey.self, perform: onMeasurementDone))
}
/// Measures the width of the view inside the current surroundings.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed width into a state variable)
func measureWidth(_ onMeasurementDone: @escaping (CGFloat) -> Void) -> some View {
measureSize { onMeasurementDone($0.width) }
}
/// Measures the height of the view inside the current surroundings.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed height into a state variable)
func measureHeight(_ onMeasurementDone: @escaping (CGFloat) -> Void) -> some View {
measureSize { onMeasurementDone($0.height) }
}
/// Measures the ideal size of the view.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed size into a state variable)
func measureIdealSize(_ onMeasurementDone: @escaping (CGSize) -> Void) -> some View {
background(fixedSize().measureSize(onMeasurementDone).hidden())
}
/// Measures the ideal width of the view.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed width into a state variable)
func measureIdealWidth(_ onMeasurementDone: @escaping (CGFloat) -> Void) -> some View {
measureIdealSize(of: self) { onMeasurementDone($0.width) }
}
/// Measures the ideal height of the view.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed height into a state variable)
func measureIdealHeight(_ onMeasurementDone: @escaping (CGFloat) -> Void) -> some View {
measureIdealSize(of: self) { onMeasurementDone($0.height) }
}
/// Measures the ideal size of the given view. This modifier is useful in case the given view is only conditionally on screen. In this case call it on any view that's always being displayed and give it the conditionally shown view to be measured.
/// - Parameter view: The view to be measured.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed size into a state variable)
func measureIdealSize<T: View>(of view: T, _ onMeasurementDone: @escaping (CGSize) -> Void) -> some View {
view.measureIdealSize(onMeasurementDone)
}
/// Measures the ideal width of the given view. This modifier is useful in case the given view is only conditionally on screen. In this case call it on any view that's always being displayed and give it the conditionally shown view to be measured.
/// - Parameter view: The view to be measured.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed width into a state variable)
func measureIdealWidth<T: View>(of view: T, _ onMeasurementDone: @escaping (CGFloat) -> Void) -> some View {
measureIdealSize(of: view) { onMeasurementDone($0.width) }
}
/// Measures the ideal height of the given view. This modifier is useful in case the given view is only conditionally on screen. In this case call it on any view that's always being displayed and give it the conditionally shown view to be measured.
/// - Parameter view: The view to be measured.
/// - Parameter onMeasurementDone: The callback (can be used to print or save the proposed height into a state variable)
func measureIdealHeight<T: View>(of view: T, _ onMeasurementDone: @escaping (CGFloat) -> Void) -> some View {
measureIdealSize(of: view) { onMeasurementDone($0.height) }
}
}
// MARK: - OffsetScrollView
/// A ScrollView that measures and updates the offset from interactions via a given Binding. Taken from here: https://zacwhite.com/2019/scrollview-content-offsets-swiftui/
struct OffsetScrollView<Content>: View where Content : View {
/// The offset of the scrollview updated as the scroll view scrolls
@Binding var offset: CGPoint
/// The content of the scroll view.
private var content: Content
/// The scrollable axes.
///
/// The default is `.vertical`.
private var axes: Axis.Set
/// If true, the scroll view may indicate the scrollable component of
/// the content offset, in a way suitable for the platform.
///
/// The default is `true`.
private var showsIndicators: Bool
/// The initial offset in the global frame, used for calculating the relative offset
@State private var initialOffset: CGPoint? = nil
init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, offset: Binding<CGPoint> = .constant(.zero), @ViewBuilder content: () -> Content) {
self.axes = axes
self.showsIndicators = showsIndicators
self._offset = offset
self.content = content()
}
/// Declares the content and behavior of this view.
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
VStack(alignment: .leading, spacing: 0) {
measurementBox
.frame(width: 0, height: 0)
content
}
}
}
private var measurementBox: some View {
GeometryReader { geometry in
Run {
let globalOrigin = geometry.frame(in: .global).origin
self.initialOffset = self.initialOffset ?? globalOrigin
let initialOffset = (self.initialOffset ?? .zero)
let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y)
self.offset = offset
}
}
}
}
/// Workaround for arbitrary code to not affect State while the View is updating (Xcode Warning)
private struct Run: View {
let block: () -> Void
var body: some View {
DispatchQueue.main.async(execute: block)
return AnyView(EmptyView())
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment