일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Tags
- authentication
- gesture
- firebase
- UIKit
- Network
- Performance
- GCD
- environmentobjet
- 네트워크
- RxSwift
- iphone
- SwiftUI
- dataflow
- realitykit
- 달력
- arkit
- 데이터최적화
- Concurrency
- CS
- fullscreencover
- auth
- WWDC
- stateobject
- swift
- combine
- state
- ios
- ar
- withAnimation
- Animation
Archives
- Today
- Total
XLOG
[SwiftUI] 밀리의 서재 책정보 Sheet 애니메이션 아이디어 및 구현 본문
친한 개발자와 얘기를 하다가 밀리의 서재에서 책을 클릭했을 때 나오는 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
'Swift > SwiftUI' 카테고리의 다른 글
[DataFlow] State, Binding, StateObject, ObservedObject, EnvironmentObject 는 무엇이고 차이는? (0) | 2024.07.01 |
---|---|
[SwiftUI] Environment 활용하기 (0) | 2024.06.25 |
[SwiftUI] View 의 Size 구하기 (0) | 2024.05.19 |
[SwiftUI] Button 의 highlighted 감지하기 (0) | 2023.12.01 |
[SwiftUI] Infinity Carousel 구현 (0) | 2023.12.01 |