지난 포스트에서 온보딩에 대한 개념들과 어떤 점을 중요하게 생각하고 사용자에게 어떻게 제공하면 좋을지 등에 대해 알아보았다.
이번에는 Kavsoft 유튜브에 소개된 프로젝트를 따라 구현하며 앱의 인트로에서 사용할 수 있는 기법들을 직접 구현해본다.
페이지를 나타낼 Page
모델이다.
원시값 타입으로 Int
를 설정해 인덱스로 활용하였고 CaseIterable
을 채택하였다.
title
description
symbolName
: SF Symbol의 이름nextPage
: 다음 페이지previousPage
: 이전 페이지import SwiftUI
enum Page: Int, CaseIterable {
case plant
case camera
case notification
var title: String {
switch self {
case .plant:
"식물 정보 확인"
case .camera:
"카메라를 통해 확인"
case .notification:
"다양한 이벤트 알림"
}
}
var description: String {
switch self {
case .plant:
"현재 기르는 식물들에 대해서 자세히 알아봐요"
case .camera:
"어떤 식물인지 또는 식물에게 아픈 곳이 있는지 사진을 찍어서 빠르고 간편하게 확인해요"
case .notification:
"물 주기, 영양분 공급 등 알림들을 설정해봐요"
}
}
var symbolName: String {
switch self {
case .plant: "camera.macro"
case .camera: "camera.viewfinder"
case .notification: "bell.fill"
}
}
var nextPage: Page {
let nextIndex = self.rawValue + 1
guard nextIndex != Page.allCases.count else { return self }
return Page.allCases[nextIndex]
}
var previousPage: Page {
let previousIndex = self.rawValue - 1
guard previousIndex != -1 else { return self }
return Page.allCases[previousIndex]
}
}
SF Symbol을 활용해서 자연스러운 애니메이션과 함께 심볼이 변하는 것을 보여주자.
전체적인 개념은 다음과 같다.
KeyframeAnimator
를 통해 0부터 임계치(여기서는 30이라 한다.)까지의 프레임을 추적하며 각 프레임마다 심볼의 모양을 변화시킨다..blur(radius:)
의 인자로 프레임 값을 사용하여 심볼이 점점 둥근 형태로 변화하게 한다.프레임이 임계치에 다다랐을 때, 기존 심볼을 새로운 심볼로 바꾼다.
임계치에서부터 다시 0으로 프레임을 추적하며 둥근 형태에서 원래의 형태로 심볼 모양을 바꾼다.
우선 심볼을 탭 했을 때 Morphing effect를 구현해보자.
KeyframeAnimator
를 통해 0에서부터 30까지 프레임을 추적하며 심볼의 모양을 .blur(radius:)
를 통해 변화시키고, 다시 30에서 0으로 프레임을 추적하며 원상복귀시킨다.
그리고 이를 Canvas
를 활용해서 렌더링하여 보여주게 된다.
struct MorphingSymbolView: View {
let symbolName: String
@State private var keyframeTrigger: Bool = false
var body: some View {
let symbolID = 0
Canvas { context, size in
context.addFilter(.alphaThreshold(min: 0.4, color: .white))
if let renderedImage = context.resolveSymbol(id: symbolID) {
context.draw(renderedImage, at: CGPoint(x: size.width / 2,
y: size.height / 2))
}
} symbols: {
SymbolView()
.tag(symbolID)
}
.frame(width: 300, height: 300)
.onTapGesture {
keyframeTrigger.toggle()
}
}
}
// MARK: - Subviews
extension MorphingSymbolView {
@ViewBuilder
private func SymbolView() -> some View {
let maxFrame: CGFloat = 30
KeyframeAnimator(initialValue: CGFloat.zero, trigger: keyframeTrigger) { value in
Image(systemName: symbolName)
.font(.system(size: 150, weight: .bold))
.frame(width: 1, height: 1) // ❗️프레임을 안잡아주면 위치가 살짝 흔들림
.blur(radius: value)
} keyframes: { _ in
CubicKeyframe(maxFrame, duration: 0.4)
CubicKeyframe(.zero, duration: 0.4)
}
}
}
maxFrame = 5 | maxFrame = 80 |
---|---|
KeyframeAnimator
를 통해 추적하는 프레임 값이 임계치 30에 다다랐을 때 기존 심볼을 새로운 심볼로 바꾸면 자연스럽게 심볼이 변화하는 애니메이션을 구현할 수 있다.
struct MorphingSymbolView: View {
let symbolName: String
@State private var currentSymbolName: String = ""
@State private var nextSymbolName: String = ""
@State private var keyframeTrigger: Bool = false
var body: some View {
let symbolID = 0
Canvas { context, size in
context.addFilter(.alphaThreshold(min: 0.4, color: .white))
if let renderedImage = context.resolveSymbol(id: symbolID) {
context.draw(renderedImage, at: CGPoint(x: size.width / 2,
y: size.height / 2))
}
} symbols: {
SymbolView()
.tag(symbolID)
}
.frame(width: 300, height: 300)
// 1️⃣ `currentSymbolName` 값을 초기화
.task {
guard currentSymbolName.isEmpty else { return }
currentSymbolName = symbolName
}
// 2️⃣ 새로운 `symbolName`이 설정되면 해당 이름을 `nextSymbolName`으로 설정하고 `KeyframeAnimator`실행
.onChange(of: symbolName) { oldValue, newValue in
nextSymbolName = newValue
keyframeTrigger.toggle()
}
}
}
// MARK: - Subviews
extension MorphingSymbolView {
@ViewBuilder
private func SymbolView() -> some View {
let maxFrame: CGFloat = 30
KeyframeAnimator(initialValue: CGFloat.zero, trigger: keyframeTrigger) { value in
Image(systemName: symbolName)
.font(.system(size: 150, weight: .bold))
.frame(width: 1, height: 1)
.blur(radius: value)
// 3️⃣ value가 임계치에 다다르면 `currentSymbolName`을 `nextSymbolName`으로 변경
.onChange(of: value) { oldValue, newValue in
if value.rounded() == maxFrame {
withAnimation(.smooth(duration: 0.5, extraBounce: 0)) {
currentSymbolName = nextSymbolName
}
}
}
} keyframes: { _ in
CubicKeyframe(maxFrame, duration: 0.4)
CubicKeyframe(.zero, duration: 0.4)
}
}
}
import SwiftUI
struct TestView: View {
@State private var symbolName: String = "gear"
var body: some View {
GeometryReader { proxy in
MorphingSymbolView(symbolName: symbolName)
.onTapGesture {
symbolName = "camera"
}
}
.background {
Rectangle()
.fill(.black.gradient)
.ignoresSafeArea()
}
}
}
기존에도 symbolEffect(_:, value:)
모디파이어가 있었지만
Xcode 16, iOS 18부터 더욱 업그레이드 되어 이를 사용하면 좋겠다고 생각이 들었다.
기존에 있던 .bounce
효과와 새롭게 추가되는 .wiggle
효과를 심볼에 맞게 사용하려고 했지만 디폴트로 제공하는 .symbolEffect()
모디파이어를 사용하는데 어려움이 있었다.
따라서 별로 마음에 들지는 않지만 일단 symbolName
에 따라 이펙트를 설정해주는 모디파이어를 새로 만들어서 사용하기로 했다.
import SwiftUI
struct SymbolEffectModifier<T: Equatable>: ViewModifier {
var symbolName: String
var trigger: T
func body(content: Content) -> some View {
if symbolName == Page.camera.symbolName {
content
.symbolEffect(.bounce, value: trigger)
} else {
content
.symbolEffect(.wiggle, value: trigger)
}
}
}
extension View {
func applySymbolEffect<T: Equatable>(symbolName: String, value trigger: T) -> some View {
return modifier(SymbolEffectModifier(symbolName: symbolName,
trigger: trigger))
}
}
import SwiftUI
struct MorphingSymbolView: View {
let symbolName: String
// 심볼 이펙트 반복을 위한 타이머
private let timer = Timer.publish(every: 3.0, on: .main, in: .common).autoconnect()
@State private var currentSymbolName: String = ""
@State private var nextSymbolName: String = ""
@State private var keyframeTrigger: Bool = false
// 심볼 이펙트 트리거
@State private var symbolEffectTrigger: Bool = false
var body: some View {
let symbolID = 0
Canvas { context, size in
context.addFilter(.alphaThreshold(min: 0.4, color: .white))
if let renderedImage = context.resolveSymbol(id: symbolID) {
context.draw(renderedImage, at: CGPoint(x: size.width / 2,
y: size.height / 2))
}
} symbols: {
SymbolView()
.tag(symbolID)
}
.frame(width: 300, height: 300)
.task {
guard currentSymbolName.isEmpty else { return }
currentSymbolName = symbolName
}
.onChange(of: symbolName) { oldValue, newValue in
nextSymbolName = newValue
keyframeTrigger.toggle()
}
// 타이머 반복 시 심볼 이펙트 트리거 작동
.onReceive(timer) { _ in
symbolEffectTrigger.toggle()
}
}
}
// MARK: - Subviews
extension MorphingSymbolView {
@ViewBuilder
private func SymbolView() -> some View {
let maxFrame: CGFloat = 30
KeyframeAnimator(initialValue: CGFloat.zero, trigger: keyframeTrigger) { value in
Image(systemName: currentSymbolName)
.font(.system(size: 150, weight: .bold))
.frame(width: 1, height: 1)
.blur(radius: value)
.onChange(of: value) { oldValue, newValue in
if value.rounded() == maxFrame {
withAnimation(.smooth(duration: 0.5, extraBounce: 0)) {
currentSymbolName = nextSymbolName
}
}
}
// 심볼 이펙트 트리거 동작 시 보여줄 이펙트 설정
.applySymbolEffect(symbolName: symbolName, value: symbolEffectTrigger)
} keyframes: { _ in
CubicKeyframe(maxFrame, duration: 0.4)
CubicKeyframe(.zero, duration: 0.4)
}
}
}
식물 - wiggle | 카메라 - bounce | 종 - wiggle |
---|---|---|
Kavsoft: SwiftUI App Intro Animation's - Shape Morphing Effect - Walkthrough Page Animation's