[iOS] 온보딩(2) - 인트로 온보딩 예시 (Morphing SFSymbol)

Charlie·2024년 8월 18일
0

SwiftUI

목록 보기
4/4
post-thumbnail

미리보기





지난 포스트에서 온보딩에 대한 개념들과 어떤 점을 중요하게 생각하고 사용자에게 어떻게 제공하면 좋을지 등에 대해 알아보았다.

이번에는 Kavsoft 유튜브에 소개된 프로젝트를 따라 구현하며 앱의 인트로에서 사용할 수 있는 기법들을 직접 구현해본다.






Model

페이지를 나타낼 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을 활용해서 자연스러운 애니메이션과 함께 심볼이 변하는 것을 보여주자.

전체적인 개념은 다음과 같다.


  1. KeyframeAnimator를 통해 0부터 임계치(여기서는 30이라 한다.)까지의 프레임을 추적하며 각 프레임마다 심볼의 모양을 변화시킨다.
    이 때, .blur(radius:) 의 인자로 프레임 값을 사용하여 심볼이 점점 둥근 형태로 변화하게 한다.
  1. 프레임이 임계치에 다다랐을 때, 기존 심볼을 새로운 심볼로 바꾼다.

  2. 임계치에서부터 다시 0으로 프레임을 추적하며 둥근 형태에서 원래의 형태로 심볼 모양을 바꾼다.






Morphing effect 구현

우선 심볼을 탭 했을 때 Morphing effect를 구현해보자.

KeyframeAnimator를 통해 0에서부터 30까지 프레임을 추적하며 심볼의 모양을 .blur(radius:)를 통해 변화시키고, 다시 30에서 0으로 프레임을 추적하며 원상복귀시킨다.
그리고 이를 Canvas를 활용해서 렌더링하여 보여주게 된다.



MorphingSymbolView 코드

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 = 5maxFrame = 80





심볼 바꾸기

KeyframeAnimator를 통해 추적하는 프레임 값이 임계치 30에 다다랐을 때 기존 심볼을 새로운 심볼로 바꾸면 자연스럽게 심볼이 변화하는 애니메이션을 구현할 수 있다.

MorphingSymbolView

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)
        }
    }
}


Client

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부터 더욱 업그레이드 되어 이를 사용하면 좋겠다고 생각이 들었다.



SymbolEffectModifier

기존에 있던 .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))
    }
}


MorphingSymbolView

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





Reference

Kavsoft: SwiftUI App Intro Animation's - Shape Morphing Effect - Walkthrough Page Animation's

profile
Hello

0개의 댓글