XLOG

[SwiftUI] 밀리의 서재 책정보 Sheet 애니메이션 아이디어 및 구현 본문

Swift/SwiftUI

[SwiftUI] 밀리의 서재 책정보 Sheet 애니메이션 아이디어 및 구현

X_PROFIT 2024. 6. 24. 16:11

친한 개발자와 얘기를 하다가 밀리의 서재에서 책을 클릭했을 때 나오는 Animation 얘기가 나와서 어떻게 구현했을까 고민을 하다가 SwiftUI 로 구현해봐야 겠다 생각해서 시작한 일이다.

 

1. 문제점

  • 기존에 Namespace 를 사용할 경우 기존 view 가 없어져야함, 하지만 위에 화면을 보게되면 기존 ScrollView는 항상 있어야 함
  • Scroll 에 따라서 해당 View 가 가지고 있는 크기 및 위치값인 CGRect 를 Update 를 해주기 위해선 PreferenceKey 를 사용해야 하는데 PreferenceKey 의 경우 하위 View 에서 상위 View의 값을 전달할 때 사용하는 것인데, FullScreenCover 는 CellView 의 상위뷰가 아님
  • present 할 때와 dismiss 할 때의 애니메이션이 다름 (dismiss 를 할 때는 기본 제공 애니메이션)

2. 아이디어

  • 첫번째 문제점의 해결방법으로는 아이템을 선택하여 FullScreenCover 가 Present 가 되면 FullScreenCover 내부에 선택한 아이템과 똑같은 크기, 똑같은 위치에 View를 그려준다. 그 후 FullScreenCover 내부에서 Namespace 를 통해 애니메이션을 적용한다. 
  • 두번째 문제의 경우 상위 View에서는 onPreferenceChange 를 통해 PreferenceValue 의 변경을 확인하여 별도에 State 로 그 값을 update 를 해준 후 item 을 선택하는 로직에서 선택한 아이템과, CGRect 값을 할당하여 FullScreenCover의 Content 의 값을 넘겨준다.
  • 상위View에서 Transaction 을 아이템을 선택했을 때와 안했을 때를 나누어 disablesAnimations 를 조절한 후, present 시 View 내부에서 각각의 애니메이션을 정의하여 동작하도록 한다.

3. 코드

  • 첫번째 문제
import SwiftUI

struct Book: Identifiable, Equatable, Hashable {
    let id: String
    let title: String
    let author: String
    let bookCover: String
}


struct BookInformationSheetView: View {

	let book: Book
	@Binding var selectedCellRect: CGRect
    
    @State private var onAppeared: Bool = false
	
	var body: some View {
    	ZStack {
            if !onAppeared {
                VStack(alignment: .leading ,spacing: 12) {
                    Image(book.bookCover)
                        .resizable()
                        .scaledToFit()
                        .matchedGeometryEffect(id: "image", in: namespace)
                    
                    VStack(alignment: .leading, spacing: 4) {
                        Text(book.title)
                            .font(.headline.weight(.bold))
                            .foregroundStyle(.black)
                            .matchedGeometryEffect(id: "title", in: namespace)
                        
                        Text(book.author)
                            .font(.subheadline.weight(.semibold))
                            .foregroundStyle(.gray)
                            .matchedGeometryEffect(id: "author", in: namespace)
                    }
                    
                }
                .frame(width: selectedCellRect.width, height: selectedCellRect.height)
                .position(x: selectedCellRect.midX, y: selectedCellRect.midY)
                .onAppear {
                	// 똑같은 위치에 View 가 나타나면 onAppear 값을 변경하여 가운데로 애니메이션 효과를 준다.
                    withAnimation(.spring(response: 0.5)) {
                        onAppeared = true
                    }
                }
            } else {
            	VStack(spacing: 12) {
                    Image(book.bookCover)
                        .resizable()
                        .scaledToFit()
                        .matchedGeometryEffect(id: "image", in: namespace)

                    VStack(spacing: 4) {
                        Text(book.title)
                            .font(.headline.weight(.bold))
                            .foregroundStyle(.black)
                            .matchedGeometryEffect(id: "title", in: namespace)

                        Text(book.author)
                            .font(.subheadline.weight(.semibold))
                            .foregroundStyle(.gray)
                            .matchedGeometryEffect(id: "author", in: namespace)
                    }
                }
                .frame(width: selectedCellRect.width, height: selectedCellRect.height)
    		}
        }
    }
}
  • 두번째 문제
struct CustomPreferenceKey<Key: Hashable, Value>: PreferenceKey {
    static var defaultValue: [Key: Value] { [:] }
    static func reduce(value: inout [Key : Value], nextValue: () -> [Key : Value]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}


// 상위뷰
struct BookShelfView: View {
	@State private var preferenceValues: [String: CGRect] = [:]
    @State private var selectedBook: Book?
    @State private var selectedCellRect: CGRect = .zero
    
    var body: some View {
    	ScrollView {
        	let column:[GridItem] = Array(repeating: .init(spacing: 16), count: 3)
            LazyVGrid(columns: column) {
            	ForEach(books, id: \.id) { book in
                    Button(action: {
                        selectedCellRect = preferenceValues[book.id] ?? .zero
                        selectedBook = book
                    }, label: {
                        BookShelfCellView(book: book)
                    })
                }
            }
        }
        .onPreferenceChange(CustomPreferenceKey<String, CGRect>.self, perform: { value in
            /// 하위 View에서 PrefereceValue 를 변경하면 그 값을 FullScreenSheet 에 넘길 수 있도록 State 값에 할당해준다.
            /// 처음 View 가 생성될 때
            /// Scroll 로 이한여 위치값이 바뀔 때
            /// 업데이트 됨
            self.preferenceValues = value
        })

    }
}

// 하위뷰

struct BookShelfCellView: View {
    let book: Book
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Image(book.bookCover)
                .resizable()
                .scaledToFit()
            
            VStack(alignment: .leading, spacing: 4, content: {
                Text(book.title)
                    .font(.headline.weight(.bold))
                    .foregroundStyle(.black)
                Text(book.author)
                    .font(.subheadline.weight(.bold))
                    .foregroundStyle(.gray)
            })
        }
        .background {
            // 해당 View의 CGRect 를 PreferenceValue 로 지정
            GeometryReader { reader in
                Color.clear.preference(key: CustomPreferenceKey<String, CGRect>.self, value: [book.id : reader.frame(in: .global)])
            }
        }
    }
}
  • 세번째 문제
struct BookShelfView: View {
	@State private var selectedBook: Book?

	var body: some View {
    	ScrollView {
        
        }
        .fullScreenCover(item: $selectedBook, content: {
			BookInformationSheetView(book: $0, selectedCellRect: $selectedCellRect)
        }
		.transaction { transaction in
            // FullScreenCover 가 present 될 때 기본 애니메이션을 막는다.
            // 반대로 dismiss 될 때 기본 애니메이션이 작동
            transaction.disablesAnimations = selectedBook != nil
        }
    }
}

struct BookInformationSheetView: View {
	let book: Book
    @Binding var selectedCellRect: CGRect

    @Environment(\.dismiss) var dismiss
    
    @Namespace private var namespace
    @State private var onAppeared: Bool = false
    @State private var isContentsShow: Bool = false
    @State private var height: CGFloat = .zero
    
    var body: some View {
    	ZStack {
        	// MARK: Background Dimming View
            if isContentsShow {
                Color.black.opacity(0.4)
                    .frame(maxHeight:.infinity)
                    .transition(.opacity)
                    .transaction {
                        $0.disablesAnimations = isContentsShow
                    }
            }
            
			/// Sheet 가 present 되면 선택한 위치에 똑같은 BookShlefCellView 와 같은 View 를 띄어줌
            /// View 가 onAppear 되면 onAppeared 값을 애니메이션을 줘서 변경
            if !onAppeared {
                VStack(alignment: .leading ,spacing: 12) {
                    Image(book.bookCover)
                        .resizable()
                        .scaledToFit()
                        .matchedGeometryEffect(id: "image", in: namespace)
                    
                    VStack(alignment: .leading, spacing: 4) {
                        Text(book.title)
                            .font(.headline.weight(.bold))
                            .foregroundStyle(.black)
                            .matchedGeometryEffect(id: "title", in: namespace)
                        
                        Text(book.author)
                            .font(.subheadline.weight(.semibold))
                            .foregroundStyle(.gray)
                            .matchedGeometryEffect(id: "author", in: namespace)
                    }
                    
                }
                .frame(width: selectedCellRect.width, height: selectedCellRect.height)
                .position(x: selectedCellRect.midX, y: selectedCellRect.midY)
                .onAppear {
                    withAnimation(.spring(response: 0.5)) {
                        onAppeared = true
                    }
                    // 애니메이션 중간에 다른 컨텐츠들에 opacity 애니메이션 적용
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: {
                        withAnimation(.spring(response: 0.3)) {
                            isContentsShow = true
                        }
                    })
                }
            }
            
            VStack {
            	if onAppeared {
                	위에 Book 정보뷰와 같은 View 생성
                }
                
                if isContentsShow {
                	VStack {
                		그외 버튼들      
                        Button(action: {
                                // Dimming View 에 dismiss 애니메이션을 막기 위해
                                isContentsShow = false
                                dismiss()
                        }, label: {
                            Text("닫기")
                                .foregroundStyle(.black)
                                .padding(16)
                                .frame(maxWidth: .infinity)
                        })
                    }
                    .transition(.opacity)
                }
            }
        }
    }
}

결과물

 

전체코드 : https://github.com/profit0124/MilliesAnimationPractice/tree/main