Last active
February 26, 2025 15:58
-
-
Save Codelaby/6f5c57b6ca4bc69a8448c62dc15e5b5d to your computer and use it in GitHub Desktop.
AppleBookScrollDemo
This file contains hidden or 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
| // | |
| // 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