
SwiftUI 3.0 Animation Challenge - App Store Hero Animation - Xcode 13 - SwiftUI Tutorials

MatchedGeometryEffect 적용// MARK: Animation Properties
    @State private var currentItem: TodayModel?
    @State private var showDetailPage: Bool = false
    // Matched Geometry Effect
    @Namespace private var animation
    // MARK: Detail Animation Properties
    @State private var animateView: Bool = false
    @State private var animateContent: Bool = false
    @State private var scrollOffset: CGFloat = 0
currentItem 프로퍼티를 통해 선택 여부와 선택 카드 종류를 캐치headerView
                .padding(.horizontal)
                .padding(.bottom)
                .opacity(showDetailPage ? 0 : 1)
.overlay {
            if
                let currentItem = currentItem,
                showDetailPage {
                DetailView(item: currentItem)
                    .ignoresSafeArea(.container, edges: .top)
            }
        }
overlay하는 해당 코드는 선택한 카드 뷰가 존재하고 디테일 뷰를 표시하라는 프로퍼티가 참일 때 상단에 디테일 뷰를 오버레이private var cardListView: some View {
        ForEach(todayItems) { item in
            Button {
                withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7)) {
                    currentItem = item
                    showDetailPage = true
                }
            } label: {
                CardView(item: item)
                    .scaleEffect(currentItem?.id == item.id && showDetailPage ? 1 : 0.93)
            }
            .buttonStyle(ScaledButtonStyle())
            .opacity(showDetailPage ? (currentItem?.id == item.id ? 1 : 0) : 1)
        }
    }
currentItem과 showDetaulPage에 값을 변경하는 버튼 이벤트@ViewBuilder
    private func CardView(item: TodayModel) -> some View {
        VStack(alignment: .leading, spacing: 15) {
            ZStack(alignment: .topLeading) {
                // Banner Image
                GeometryReader { proxy in
                    let size = proxy.size
                    Image(item.artwork)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: size.width, height: size.height)
                        .clipShape(CustomCorner(corners: [.topLeft, .topRight], radius: 15))
                }
                .frame(height: 400)
                
                // linear gradient
                ...
                
                // banner title and description
                ...
                
                // app logo
                ...
                
                // button
                ...
        }
        .background {
            RoundedRectangle(cornerRadius: 15, style: .continuous)
                .fill(Color(.tertiarySystemBackground))
        }
         .matchedGeometryEffect(id: item.id, in: animation)
...
GeometryReader를 통해 현재 뷰가 존재하는 프레임 값을 읽어온 뒤 카드에 적용clipShape를 통해 해당 위치에만 radius를 주기linear gradient와 배너 타이틀 및 앱 로고, 버튼 등을 띄우는 UI 구현 과정 .matchedGeometryEffect(id: item.id, in: animation)를 통해 현재 카드 뷰와 이후 카드 뷰를 클릭할 때 등장하는 디테일 뷰가 동일한 컴포넌트로서 인식되어야 함을 알려주기.offset(y: currentItem?.id == item.id && animateView ? safeArea().top : 0)
ScrollView(.vertical, showsIndicators: false) {
            VStack {
                CardView(item: item)
                    .scaleEffect(animateView ? 1 : 0.93)
                
                VStack(spacing: 15) {
                // Detail Content for CardView
                ...
                        }
                    }
                }
.onAppear {
            withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7)) {
                animateView = true
            }
            withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7)) {
                animateContent = true
            }
        }
animateView와 aniamteContent가 참으로 값 변경CardView(item: item)
                    .scaleEffect(animateView ? 1 : 0.93)
                    ...
// Detail Content View's VStack
.opacity(animateContent ? 1 : 0)
.offset(y: scrollOffset > 0 ? -scrollOffset : 0)
.overlay {
                GeometryReader { proxy in
                    let minY = proxy.frame(in: .named("SCROLL")).minY
                    Color.clear
                        .preference(key: OffsetKey.self, value: minY)
                }
                .onPreferenceChange(OffsetKey.self) { value in
                    scrollOffset = value
                }
            }
y 값은 스크롤 오프셋이 0보다 크다면 그 역을, 그렇지 않다면 0을 취함. y 값이 0이라는 뜻은 곧 가장 top일 때GeometryReader를 통해 현재 프레임의 minY 값을 읽어올 수 있음 (이는 스크롤 뷰 자체에  .coordinateSpace(name: "SCROLL")을 주었기 때문에 해당 이름으로 읽어오기 가능) → 해당 값이 변경될 때마다 scrollOffset @State 프로퍼티 값을 변경 가능import SwiftUI
struct HomeView: View {
    // MARK: Animation Properties
    @State private var currentItem: TodayModel?
    @State private var showDetailPage: Bool = false
    // Matched Geometry Effect
    @Namespace private var animation
    // MARK: Detail Animation Properties
    @State private var animateView: Bool = false
    @State private var animateContent: Bool = false
    @State private var scrollOffset: CGFloat = 0
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            VStack(spacing: 30) {
                headerView
                .padding(.horizontal)
                .padding(.bottom)
                .opacity(showDetailPage ? 0 : 1)
                cardListView
            }
            .padding(.vertical)
        }
        .overlay {
            if
                let currentItem = currentItem,
                showDetailPage {
                DetailView(item: currentItem)
                    .ignoresSafeArea(.container, edges: .top)
            }
        }
        .background(alignment: .top) {
            RoundedRectangle(cornerRadius: 15, style: .continuous)
                .fill(Color(.tertiarySystemBackground))
                .frame(height: animateView ? nil : 350, alignment: .top)
                .opacity(animateView ? 1 : 0)
                .ignoresSafeArea()
        }
    }
}
extension HomeView {
    private var headerView: some View {
        HStack(alignment: .bottom) {
            VStack(alignment: .leading, spacing: 8) {
                Text("SATURDAY 26 NOVEMBER")
                    .font(.callout)
                    .foregroundColor(.gray)
                Text("Today")
                    .font(.largeTitle.bold())
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            
            Button {
                
            } label: {
                Image(systemName: "person.circle.fill")
                    .font(.largeTitle)
            }
        }
    }
    
    private var cardListView: some View {
        ForEach(todayItems) { item in
            Button {
                withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7)) {
                    currentItem = item
                    showDetailPage = true
                }
            } label: {
                CardView(item: item)
                    .scaleEffect(currentItem?.id == item.id && showDetailPage ? 1 : 0.93)
            }
            .buttonStyle(ScaledButtonStyle())
            .opacity(showDetailPage ? (currentItem?.id == item.id ? 1 : 0) : 1)
        }
    }
    
    // MARK: CardView
    @ViewBuilder
    private func CardView(item: TodayModel) -> some View {
        VStack(alignment: .leading, spacing: 15) {
            ZStack(alignment: .topLeading) {
                // Banner Image
                GeometryReader { proxy in
                    let size = proxy.size
                    Image(item.artwork)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: size.width, height: size.height)
                        .clipShape(CustomCorner(corners: [.topLeft, .topRight], radius: 15))
                }
                .frame(height: 400)
                
                LinearGradient(colors: [
                    .black.opacity(0.5),
                    .black.opacity(0.2),
                    .clear
                ], startPoint: .top, endPoint: .bottom)
                
                VStack(alignment: .leading, spacing: 8) {
                    Text(item.platformTitle.uppercased())
                        .font(.callout)
                        .fontWeight(.semibold)
                    Text(item.bannerTitle)
                        .font(.largeTitle.bold())
                        .multilineTextAlignment(.leading)
                }
                .foregroundColor(.primary)
                .padding()
                .offset(y: currentItem?.id == item.id && animateView ? safeArea().top : 0)
            }
            HStack(spacing: 12) {
                Image(item.appLogo)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: 60, height: 60)
                    .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
                VStack(alignment: .leading, spacing: 4) {
                    Text(item.platformTitle.uppercased())
                        .font(.caption)
                        .foregroundColor(.gray)
                    Text(item.appName)
                        .fontWeight(.bold)
                    Text(item.appDescription)
                        .font(.caption)
                        .foregroundColor(.gray)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                
                Button {
                    
                } label: {
                    Text("GET")
                        .fontWeight(.bold)
                        .foregroundColor(.blue)
                        .padding(.vertical, 8)
                        .padding(.horizontal, 20)
                        .background(Capsule().fill(.ultraThinMaterial))
                }
            }
            .padding([.horizontal, .bottom])
        }
        .background {
            RoundedRectangle(cornerRadius: 15, style: .continuous)
                .fill(Color(.tertiarySystemBackground))
        }
        .matchedGeometryEffect(id: item.id, in: animation)
    }
    
    func DetailView(item: TodayModel) -> some View {
        ScrollView(.vertical, showsIndicators: false) {
            VStack {
                CardView(item: item)
                    .scaleEffect(animateView ? 1 : 0.93)
                
                VStack(spacing: 15) {
                    Text(item.appDetailDescription)
                        .multilineTextAlignment(.leading)
                        .lineSpacing(10)
                        .padding(.bottom, 20)
                    Divider()
                    
                    Button {
                        
                    } label: {
                        Label {
                            Text("Share Story")
                        } icon: {
                            Image(systemName: "square.and.arrow.up.fill")
                        }
                        .foregroundColor(.primary)
                        .padding(.vertical, 5)
                        .padding(.horizontal, 25)
                        .background {
                            RoundedRectangle(cornerRadius: 5, style: .continuous)
                                .fill(.ultraThinMaterial)
                        }
                    }
                }
                .padding()
                .offset(y: scrollOffset > 0 ? scrollOffset : 0)
                .opacity(animateContent ? 1 : 0)
                .scaleEffect(animateView ? 1 : 0, anchor: .top)
            }
            .offset(y: scrollOffset > 0 ? -scrollOffset : 0)
            .overlay {
                GeometryReader { proxy in
                    let minY = proxy.frame(in: .named("SCROLL")).minY
                    Color.clear
                        .preference(key: OffsetKey.self, value: minY)
                }
                .onPreferenceChange(OffsetKey.self) { value in
                    scrollOffset = value
                }
            }
        }
        .coordinateSpace(name: "SCROLL")
        .overlay(alignment: .topTrailing) {
            Button {
                // Closing Views
                withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7, blendDuration: 0.7)) {
                    animateView = false
                    animateContent = false
                }
                
                withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7).delay(0.05)) {
                    currentItem = nil
                    showDetailPage = false
                }
            } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.title)
                    .foregroundColor(.primary)
            }
            .padding()
            .padding(.top, safeArea().top)
            .offset(y: -10)
            .opacity(animateView ? 1 : 0)
        }
        .onAppear {
            withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7)) {
                animateView = true
            }
            withAnimation(.interactiveSpring(response: 0.6, dampingFraction: 0.7)) {
                animateContent = true
            }
        }
        .transition(.identity)
    }
}
matchedGeometryEffect가 핵심 로직import Foundation
struct TodayModel: Identifiable {
    let id = UUID().uuidString
    let appName: String
    let appDescription: String
    let appLogo: String
    let bannerTitle: String
    let platformTitle: String
    let artwork: String
    let appDetailDescription: String
}
let todayItems: [TodayModel] = [
    TodayModel(appName: "Figma", appDescription: "Design App", appLogo: "logo_1", bannerTitle: "Browse, view, play your designs anywhere", platformTitle: "Utilities", artwork: "post_1", appDetailDescription: """
               Keep your designs mobile with the Figma app.
               
               Bring your creations to life, wherever you are, for convenient and immersive viewing. Share, browse, and view your designs with just a few taps.
               
               With Figma’s mobile app, you can:
               
               - View, browse, and share files and prototypes
               - Navigate team and project folders
               - Favorite files for even faster access
               - Playback prototypes without being tethered to your desktop
               - Turn on hot spots in prototypes for easier navigation
               - Mirror selected frames from desktop onto your mobile device
               
               On iPad, you can also use the Figma app to:
               
               - Sketch with the Apple Pencil to explore and iterate on ideas more fluidly
               - Share and riff on early thinking with your team
               - Annotate designs to share feedback
               - Jot down ideas whenever inspiration strikes
               
               We’re excited to release more features soon!
               
               If you have any feedback you can report issues in-app from your account settings.
               """),
    TodayModel(appName: "Apple Developer", appDescription: "Developer App", appLogo: "logo_2", bannerTitle: "Developer app with Apple tools", platformTitle: "Developer Tools", artwork: "post_2", appDetailDescription: """
Welcome to Apple Developer, your source for developer stories, news, and educational information — and the best place to experience WWDC.
• Stay up to date on the latest technical and community information.
• Browse news, features, developer stories, and informative videos.
• Catch up on videos from past events and download them to watch offline.
Thank you for your feedback. New in this release:
• A new UI designed for macOS.
• Discover, which helps you catch up on the latest stories, news, videos, and more.
• WWDC, where you can find everything you’ll need for the conference.
• A new browse interface, where you can search for existing sessions, videos, articles, and news.
• The option to download and favorite content to read or watch later.
""")
]
import SwiftUI
struct ScaledButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label.scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.easeInOut, value: configuration.isPressed)
    }
}
import SwiftUI
struct CustomCorner: Shape {
    var corners: UIRectCorner
    var radius: CGFloat
    
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
        return Path(path.cgPath)
    }
}
import SwiftUI
struct OffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
PreferenceKey를 통해 현 시점의 하위 뷰인 디테일 뷰 내 스크롤 뷰의 GeometrReader를 통해 읽어온 값을 상위 뷰인 홈 뷰에 전달하기 위한 커스텀 Preferenceimport SwiftUI
extension View {
    func safeArea() -> UIEdgeInsets {
        guard
            let screen = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let safeArea = screen.windows.first?.safeAreaInsets else { return .zero }
        return safeArea
    }
}
safeArea 자체를 읽어오는 코드
코드를 그대로 읽고 작성하는 것밖에 하지 않았지만 근래 들어 본 강의 내용 중에 가장 어려웠다. 선언형 언어인 SwitUI 사용 방법에 보다 익숙해져야겠다. 한 번 개념적으로나마 익혀볼까, 하고 생각했던
PreferenceKey,MatchedGeometryEffect,GeometryReader등 UI 컴포넌트를 다룰 때 익혔던 고급 개념들을 함께 사용해야 하니 말이다.