Skip to content

Instantly share code, notes, and snippets.

@Codelaby
Last active February 26, 2025 15:58
Show Gist options
  • Select an option

  • Save Codelaby/6f5c57b6ca4bc69a8448c62dc15e5b5d to your computer and use it in GitHub Desktop.

Select an option

Save Codelaby/6f5c57b6ca4bc69a8448c62dc15e5b5d to your computer and use it in GitHub Desktop.
AppleBookScrollDemo
//
// AppleBookScrollDemo.swift
// IOS18Playground
//
// Created by Codelaby on 26/2/25.
//
import SwiftUI
struct Book: Identifiable, Hashable, Sendable {
var id: UUID = .init()
var title: String
var author: String
var rating: String
var thumbnail: String
var color: Color
}
let sampleBooks: [Book] = [
Book(
title: "The Lexington Letter",
author: "Anonymous",
rating: "4.8 (32) • Crime & Thrillers",
thumbnail: "Book 1",
color: Color.blue // Replace with your actual color
),
Book(
title: "The You You Are",
author: "Dr. Ricken Lazlo Hale, PhD",
rating: "4.5 (6) • Health & Wellness",
thumbnail: "Book 2",
color: Color.green // Replace with your actual color
),
Book(
title: "Mystery at the Lighthouse",
author: "Emma Stone",
rating: "4.2 (18) • Mystery",
thumbnail: "Book 3",
color: Color.purple // Replace with your actual color
),
Book(
title: "Journey to the Stars",
author: "Alexander Sky",
rating: "4.7 (25) • Science Fiction",
thumbnail: "Book 4",
color: Color.orange // Replace with your actual color
),
Book(
title: "Whispers of the Forest",
author: "Luna Woods",
rating: "4.6 (30) • Fantasy",
thumbnail: "Book 5",
color: Color.indigo // Replace with your actual color
)
]
let dummyText: String = """
Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,
when an unknown printer took a galley of type and scrambled it to make a type
specimen book. It has survived not only five centuries, but also the leap into
electronic typesetting, remaining essentially unchanged. It was popularised in
the 1960s with the release of Letraset sheets containing Lorem Ipsum passages,
and more recently with desktop publishing software.
"""
extension View {
func serifText(_ font: Font, weight: Font.Weight) -> some View {
self.font(font)
.fontWeight(weight)
.fontDesign(.serif)
}
}
extension ScrollGeometry {
var offsetY: CGFloat {
contentOffset.y + contentInsets.top
}
var topInsetProgress: CGFloat {
guard contentInsets.top > 0 else { return 0}
return max(min(offsetY / contentInsets.top, 1), 0)
}
}
struct BookScrollEnd: ScrollTargetBehavior {
var topInset: CGFloat
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
if target.rect.minY < topInset {
target.rect.origin = .zero
}
}
}
struct BookCardView: View {
var book: Book
var parentHorizontalPadding: CGFloat = 15
//var size: CGSize
var isScrolled: (Bool) -> ()
@State private var scrollProperties: ScrollGeometry = .init(contentOffset: .zero, contentSize: .zero, contentInsets: .init(), containerSize: .zero)
@State private var scrollPosition: ScrollPosition = .init()
@State private var isPageScrolled: Bool = false
var body: some View {
GeometryReader { geometry in
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 15) {
// Content of Book Card
TopCardView()
.zIndex(1)
// Above Header
VStack {
Text(dummyText)
Text(dummyText)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 15)
.frame(maxWidth: geometry.size.width - parentHorizontalPadding * 2) // avoid adapt text when expand
//.offset(y: scrollProperties.offsetY > 0 ? 0 : -scrollProperties.offsetY)
}
.background(.background) // background Content Book
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20))
}
.background(alignment: .bottom) {
Rectangle()
.fill(.background) // background Full Book Card
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20))
.offset(y: scrollProperties.offsetY > 0 ? 0 : -scrollProperties.offsetY)
.ignoresSafeArea(.all, edges: .bottom)
}
.padding(.horizontal, -parentHorizontalPadding * scrollProperties.topInsetProgress)
.scrollPosition($scrollPosition)
.onScrollGeometryChange(for: ScrollGeometry.self, of: {$0}, action: { oldValue, newValue in
scrollProperties = newValue
isPageScrolled = newValue.offsetY > 0
})
.scrollTargetBehavior(BookScrollEnd(topInset: scrollProperties.contentInsets.top)) // Adding magnetic effect
.onChange(of: isPageScrolled) { oldValue, newValue in
isScrolled(newValue)
}
}
}
@ViewBuilder
private func TopCardView() -> some View {
VStack(spacing:15) {
FixedHeaderView()
.zIndex(10)
.background(alignment: .bottom) {
Rectangle()
.fill(.ultraThinMaterial.opacity(1 * scrollProperties.topInsetProgress))
.frame(height: max(min(40 + scrollProperties.offsetY, 0), 120))
.mask(
LinearGradient(
gradient: Gradient(colors: [.black, .black, .clear]),
startPoint: .top,
endPoint: .bottom
)
)
.offset(y: scrollProperties.offsetY - 10)
}
// Book Main contents
Image(book.thumbnail)
.resizable()
.aspectRatio(2 / 3, contentMode: .fit)
.containerRelativeFrame(.horizontal) { length, _ in
length * 0.6
}
.padding(.top, 10)
Text(book.title)
.serifText(.title2, weight: .semibold)
// .font(.title2)
// .fontDesign(.serif)
// .fontWeight(.semibold)
}
.background {
Rectangle()
.fill(book.color.gradient)
}
}
@ViewBuilder
private func FixedHeaderView() -> some View {
HStack(spacing: 10) {
Button("Close", systemImage: "xmark.circle.fill") {
withAnimation(.easeInOut) {
scrollPosition.scrollTo(edge: .top)
}
}
.imageScale(.large)
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.foregroundStyle(.white, .white.tertiary)
Spacer()
Button("Add", systemImage: "plus.circle.fill") {
print("click Add")
}
.imageScale(.large)
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.foregroundStyle(.white, .white.tertiary)
//let _ = print(scrollProperties.topInsetProgress)
}
.padding(.top, 10) // Padding collapsed card
.padding(.horizontal, 8)
.padding(.horizontal, (4 * scrollProperties.topInsetProgress))
.padding(.top, (-10 * scrollProperties.topInsetProgress))
//.padding(.horizontal, (-parentHorizontalPadding / 2) * scrollProperties.topInsetProgress)
.offset(y: scrollProperties.offsetY < 20 ? 0 : scrollProperties.offsetY - 20) // Sticky position in top safe area
}
}
#Preview {
GeometryReader { proxy in
BookCardView(book: sampleBooks[0], parentHorizontalPadding: 30) { isScrolled in
}
.padding(.horizontal, 30)
}
.background(.gray)
}
struct AppleBookScrollDemo: View {
@State private var isAnyBookCardScrolled: Bool = false
@State private var scrollPosition: ScrollPosition = .init(idType: UUID.self)
// Inicializamos currentID con un valor por defecto, se actualizará en onAppear
@State private var currentID: UUID
// Suponiendo que books es una propiedad o variable definida
let books: [Book]
init() {
self.books = sampleBooks
// Inicializamos el valor inicial de currentID en el init
_currentID = State(initialValue: books.first?.id ?? UUID())
}
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 5) {
ForEach(books, id: \.self.id) { book in
let isSelected = currentID == book.id
//let _ = print("book \(book.id) \(isSelected)")
BookCardView(book: book) { isScrolled in
isAnyBookCardScrolled = isScrolled
}
.id(book.id)
.zIndex(isSelected ? 20 : 0)
.containerRelativeFrame(.horizontal)
//.frame(width: geometry.size.width)
}
}
.scrollTargetLayout()
}
.safeAreaPadding(15)
.scrollTargetBehavior(.viewAligned(limitBehavior: .always))
.scrollPosition($scrollPosition)
.scrollDisabled(isAnyBookCardScrolled)
.ignoresSafeArea(.all, edges: [.leading, .trailing])
.background(.quinary)
.onChange(of: scrollPosition) { oldValue, newValue in
if let selection = newValue.viewID(type: UUID.self) {
currentID = selection
}
}
}
}
#Preview {
AppleBookScrollDemo()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment