Skip to content

Instantly share code, notes, and snippets.

@tarrouye
Created December 3, 2024 00:43
Show Gist options
  • Save tarrouye/9548dd583204cdb706eabf7a894ba6fd to your computer and use it in GitHub Desktop.
Save tarrouye/9548dd583204cdb706eabf7a894ba6fd to your computer and use it in GitHub Desktop.
An example of how to implement a UI based on this video: https://www.reddit.com/r/SwiftUI/comments/1h50asu/how_to_recreate_this_scroll_view/
//
// ScrollIntoSheet.swift
// (c) Théo Arrouye 2024
//
//
import SwiftUI
struct ScrollIntoSheetView: View {
let backgroundColor: Color = .black
let sheetColor: Color = .white
@State var offset: CGFloat = 0
@State var viewHeight: CGFloat = 0
@State var headerHeight: CGFloat = 0
@State var headerHeight2: CGFloat = 0
@State var contentOffset: CGFloat = 0
@State var safeAreaInsets: EdgeInsets = .zero
@State private var scrollPosition = ScrollPosition(edge: .top)
let data = SectionData.getMockData()
var sheetOpenFactor: CGFloat = 2.0
var sheetHeight: CGFloat {
let defaultHeight = viewHeight - headerHeight
let offset = contentOffset > 0 ? contentOffset * sheetOpenFactor : contentOffset
let maximumHeight = viewHeight + safeAreaInsets.top
return max(0, min(maximumHeight, defaultHeight + offset))
}
var sheetAtTopOffset: CGFloat {
(headerHeight + safeAreaInsets.top) / sheetOpenFactor
}
var scrollHeaderPadding: CGFloat {
let offsetToUse = min(contentOffset, sheetAtTopOffset)
let neededPadding = headerHeight2 - (headerHeight - offsetToUse)
let clamped = max(0, neededPadding)
return clamped + 10 /// add 10 to give a little extra padding inside our 'sheet' always
}
var secondHeaderOffset: CGFloat {
max(0, contentOffset - sheetAtTopOffset)
}
var body: some View {
ZStack(alignment: .top) {
// (1) back everything with sheet color going behind safe areas
sheetColor
.ignoresSafeArea(edges: .all)
.zIndex(0)
// (2) above that, put the top half background color, going behind the top safe area
// (not the bottom edge since we will see that behind the sheet)
backgroundColor
.ignoresSafeArea(edges: .top)
.zIndex(1)
// (3) The header for when the sheet is closed
HeaderView(style: .sheetClosed)
// (4) We observe the size of this view to do some calculations with
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
headerHeight = newHeight
}
// (5) Put it on top when the sheet is closed so it can be interacted with
.zIndex(contentOffset > 0 ? 2 : 6)
// (6) The sheet itself
sheet
.zIndex(3)
// (7) Our scroll view
ScrollView(.vertical, showsIndicators: false) {
// (8) Stub for header space in scrollview
Color.clear
.frame(height: headerHeight)
// (9) This padding makes sure the bottom content aligns with the headers
// in either state (see the property to see the maths)
.padding(.bottom, scrollHeaderPadding)
// (10) The sheet content
ForEach(data, id: \.title) { data in
SectionView(data: data)
}
}
// (11) Observe the scroll content offset to drive our UI between states
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top
} action: { _, newOffset in
contentOffset = newOffset
}
// (12) Automatically close/open the sheet if user lets go between states
.onScrollPhaseChange { old, new in
guard old != new, new == .idle else { return }
scrollToNearestSheetPosition()
}
// (13) This is how we will drive the offset of the scrollview programatically
.scrollPosition($scrollPosition)
.zIndex(4)
// (14) The header for when the sheet is open
HeaderView(style: .sheetOpen)
// (15) We observe the size of this view to do some calculations with
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
headerHeight2 = newHeight
}
.frame(maxHeight: .infinity, alignment: .top)
// (16) We mask this view with our sheet so that it appears on top of the old one as the
// sheet passes it
.mask {
sheet
}
// (17) Since this header isn't actually inside the scroll view, we need
// to offset it ourselves when it should scroll.
.offset(y: -secondHeaderOffset)
.zIndex(5)
}
// (18) We observe the height and safe area of the full view
// in order to use in our calculations
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
if newHeight > viewHeight {
viewHeight = newHeight
}
}
.onGeometryChange(for: EdgeInsets.self) { proxy in
proxy.safeAreaInsets
} action: { newInsets in
guard safeAreaInsets == .zero else { return }
safeAreaInsets = newInsets
}
}
private var sheet: some View {
Color.clear
.ignoresSafeArea()
.background(alignment: .bottom) {
sheetColor
.clipShape(
.rect(cornerRadii:
.init(
topLeading: 30,
bottomLeading: 0,
bottomTrailing: 0,
topTrailing: 30
)
)
)
.frame(height: sheetHeight)
.overlay(alignment: .top) {
// (19) Display the grabber
Capsule()
.foregroundStyle(.secondary).colorScheme(.dark)
.frame(width: 60, height: 5)
.offset(y: -15)
.opacity(contentOffset > 0 ? 0 : 1) // (19b) only if the sheet is closed
}
}
}
private func scrollToNearestSheetPosition() {
// only do this if the sheet is between states
guard contentOffset > 0, contentOffset < sheetAtTopOffset else {
return
}
// scroll to open/close which is closest
let target = contentOffset > sheetAtTopOffset / 2 ? sheetAtTopOffset : 0
withAnimation(.spring) {
scrollPosition.scrollTo(y: target)
}
}
}
// MARK: - Header view (sheet open/closed variants)
struct HeaderView: View {
enum Style {
case sheetClosed
case sheetOpen
fileprivate var colorScheme: ColorScheme {
switch self {
case .sheetOpen: .light
case .sheetClosed: .dark
}
}
}
let style: Style
var body: some View {
VStack {
header
switch style {
case .sheetOpen:
addNewButton
case .sheetClosed:
MetricsView()
}
}
.colorScheme(style.colorScheme)
.padding(.bottom)
}
private var header: some View {
VStack(alignment: .leading) {
Text("Today")
.foregroundStyle(.primary)
Text("December 02")
.foregroundStyle(.secondary)
}
.font(.largeTitle.bold())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
@State private var isShowingAlert: Bool = false
private var addNewButton: some View {
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 80)
.shadow(color: .black.opacity(0.2), radius: 6)
.overlay {
Image(systemName: "plus")
.font(.largeTitle.bold())
.foregroundStyle(.secondary)
}
.padding()
.onTapGesture {
isShowingAlert.toggle()
}
.alert(isPresented: $isShowingAlert) {
Alert(title: Text("Add new pressed"))
}
}
}
// MARK: Metrics View
struct MetricsView: View {
let metrics: [Metric] = Metric.getMockData()
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(metrics, id: \.symbolName) { metric in
MetricCellView(metric: metric)
}
}
.padding()
}
}
}
struct MetricCellView: View {
let metric: Metric
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Image(systemName: metric.symbolName)
.font(.largeTitle)
.foregroundStyle(.secondary)
Spacer()
Text("\(metric.count)")
.font(.headline.bold())
.foregroundStyle(.white)
Text(metric.title)
.font(.headline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding()
.frame(width: 110, height: 150, alignment: .leading)
.background(Color(UIColor.systemGray5))
.clipShape(.rect(cornerRadius: 15))
}
}
// MARK: - Bottom List Subviews
struct SectionView: View {
let data: SectionData
var body: some View {
Section {
ForEach(data.items, id: \.self) { col in
ListCellView(color: col)
}
} header: {
if let title = data.title {
Text(title)
.font(.headline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top)
}
}
.padding(.horizontal)
}
}
struct ListCellView: View {
let color: Color
var body: some View {
HStack {
color
.frame(width: 60, height: 60)
.clipShape(.rect(cornerRadius: 10))
VStack(alignment: .leading) {
Text("LORUM IPSUM")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Lorum ipsum dolor and other things")
.lineLimit(1)
.font(.headline)
.foregroundStyle(.primary)
Text("Lorum")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white)
.clipShape(.rect(cornerRadius: 10))
.shadow(color: .black.opacity(0.2), radius: 6)
}
}
// MARK: - Data Models & Mock Data
struct Metric {
let title: LocalizedStringKey
let symbolName: String
let count: Int
}
extension Metric {
static func getMockData() -> [Metric] {
[
.init(title: "Workout", symbolName: "dumbbell", count: 0),
.init(title: "Podcasts", symbolName: "moon.stars", count: 1),
.init(title: "Steps", symbolName: "speedometer", count: 300),
.init(title: "Another one", symbolName: "sun.haze.circle.fill", count: 0)
]
}
}
struct SectionData {
let title: String?
let items: [Color]
}
extension SectionData {
static func getMockData() -> [SectionData] {
[
.init(title: nil, items: [.blue, .green]),
.init(title: "December 01", items: [.red, .yellow, .orange]),
.init(title: "November 31", items: [.purple, .pink, .black, .gray, .blue, .green])
]
}
}
// MARK: - Preview
#Preview {
ScrollIntoSheetView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment