[SwiftUI] Neumorphism Advanced

Junyoung Park·2022년 9월 11일
0

SwiftUI

목록 보기
69/136
post-thumbnail
post-custom-banner

SwiftUI Long Press Gesture and Tap Progress Animation

Neumorphism Advanced

구현 목표

  • 뉴모피즘 스타일을 적용한 컴포넌트 구현
  • 컴포넌트 애니메이션 스타일 적용

구현 태스크

  1. 뉴모피즘 스타일의 백그라운드 구현
  2. 탭 제스처 → 버튼 크기 조절 애니메이션
  3. 프레스 제스처 → 버튼 컴포넌트 내 이미지 위치, 색상, 종류 변경 애니메이션

소스 코드

import SwiftUI

struct NeumorphismBootCampAdvanced: View {
    let customWhite = Color.init(red: 0.7608050108, green: 0.8164883852, blue: 0.9259157777)
    var body: some View {
        VStack(alignment: .center, spacing: 60) {
            RectangleButtonView()
            CircleButton()
            PayButton()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(customWhite)
        .edgesIgnoringSafeArea(.all)
        .animation(.spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0))
    }
}
  • 직사각형 버튼 뷰, 원형 버튼 뷰, 지문 인식 버튼 뷰
struct RectangleButtonView: View {
    let customWhite = Color.init(red: 0.7608050108, green: 0.8164883852, blue: 0.9259157777)
    @State private var isTapped: Bool = false
    @State private var isPressed: Bool = false
    var body: some View {
        Text("Button")
            .foregroundColor(.black)
            .font(.system(size: 20, weight: .semibold, design: .rounded))
            .frame(width: 200, height: 60)
            .background(
                ZStack {
                    isPressed ? Color.white : customWhite
                    RoundedRectangle(cornerRadius: 16, style: .continuous)
                        .foregroundColor(isPressed ? customWhite : Color.white)
                        .blur(radius: 4)
                        .offset(x: -8, y: -8)
                    
                    RoundedRectangle(cornerRadius: 16, style: .continuous)
                        .fill(
                            LinearGradient(gradient: Gradient(colors: [customWhite, .white]), startPoint: .topLeading, endPoint: .bottomTrailing)
                        )
                        .padding(2)
                        .blur(radius: 2)
                }
            )
            .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
            .overlay(
                HStack {
                    Image(systemName: "person.crop.circle")
                        .font(.system(size: 24, weight: .light))
                        .foregroundColor(Color.white.opacity(isPressed ? 0 : 1))
                        .frame(width: isPressed ? 64 : 54, height: isPressed ? 4 : 50)
                        .background(Color.purple)
                        .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
                        .shadow(color: Color.purple, radius: 10, x: 10, y: 10)
                        .offset(x: isPressed ? 70 : -10, y: isPressed ? 16 : 0)
                    Spacer()
                }
            )
            .shadow(color: isPressed ? Color.white : customWhite, radius: 20, x: 20, y: 20)
            .shadow(color: isPressed ? customWhite : Color.white, radius: 20, x: -20, y: -20)
            .scaleEffect(isTapped ? 1.2 : 1)
            .gesture(
                LongPressGesture(minimumDuration: 0.5, maximumDistance: 10)
                    .onChanged({ value in
                    isTapped = true
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                        self.isTapped = false
                    }
                })
                    .onEnded({ value in
                        isPressed.toggle()
                    })
            )
    }
}
  • 텍스트 버튼 형태의 모디파이어 → ZStack 겹친 직사각형 뷰를 통해 내/외부 그림자 표현
  • 오버레이 이미지 → 버튼 위에 person.crip.circle 이미지 놓기, 프레스 제스처에 따라 위치/형태 변경
  • LongPressGesture를 통해 탭/프레스 제스처 모두 조정
struct CircleButton: View {
    let customWhite = Color.init(red: 0.7608050108, green: 0.8164883852, blue: 0.9259157777)
    @State private var isTapped: Bool = false
    @State private var isPressed: Bool = false
    var body: some View {
        ZStack {
            Image(systemName: "sun.max")
                .foregroundColor(.black)
                .font(.system(size: 44, weight: .light))
                .offset(x: isPressed ? -90 : 0, y: isPressed ? -90 : 0)
                .rotation3DEffect(Angle(degrees: isPressed ? 20 : 0), axis: (x: 10, y: -10, z: 0))
            Image(systemName: "moon")
                .foregroundColor(.black)
                .font(.system(size: 44, weight: .light))
                .offset(x: isPressed ? 0 : 90, y: isPressed ? 0 : 90)
                .rotation3DEffect(Angle(degrees: isPressed ? 0 : 20), axis: (x: -10, y: 10, z: 0))
        }
        .frame(width: 100, height: 100)
        .background(
            ZStack {
                LinearGradient(gradient: Gradient(colors: isPressed ? [customWhite, .white] : [.white, customWhite]), startPoint: .topLeading, endPoint: .bottomTrailing)
                Circle()
                    .stroke(Color.clear, lineWidth: 10)
                    .shadow(color: isPressed ? Color.white : customWhite, radius: 3, x: -5, y: -5)
                Circle()
                    .stroke(Color.clear, lineWidth: 10)
                    .shadow(color: isPressed ? customWhite : Color.white, radius: 3, x: 3, y: 3)
            }
        )
        .clipShape(Circle())
        .shadow(color: isPressed ? customWhite : .white, radius: 20, x: -20, y: -20)
        .shadow(color: isPressed ? .white : customWhite, radius: 20, x: 20, y: 20)
        .scaleEffect(isTapped ? 1.2 : 1)
        .gesture(
            LongPressGesture()
                .onChanged({ value in
                isTapped = true
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    self.isTapped = false
                }
            })
                .onEnded({ value in
                    isPressed.toggle()
                })
        )
    }
}
  • 해/달 이미지 컴포넌트의 위치값 ZStack 및 오프셋 y 값을 통해 변경하는 것처럼 보이게 구현
struct PayButton: View {
    let customWhite = Color.init(red: 0.7608050108, green: 0.8164883852, blue: 0.9259157777)
    @GestureState var isTapped: Bool = false
    @State var isPressed: Bool = false
    var body: some View {
        ZStack {
            Image("fingerprint_off")
                .resizable()
                .scaledToFit()
                .frame(width: 70, height: 70)
                .opacity(isPressed ? 0 : 1)
                .scaleEffect(isPressed ? 0 : 1)
                
            Image("fingerprint_on")
                .resizable()
                .scaledToFit()
                .frame(width: 70, height: 70)
                .clipShape(Rectangle().offset(y: isTapped ? 10 : 100))
                .animation(.easeInOut)
                .opacity(isPressed ? 0 : 1)
                .scaleEffect(isPressed ? 0 : 1)
            
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 44, weight: .light))
                .foregroundColor(Color.purple)
                .opacity(isPressed ? 1 : 0)
                .scaleEffect(isPressed ? 1 : 0)

        }
        .frame(width: 120, height: 120)
        .background(
            ZStack {
                LinearGradient(gradient: Gradient(colors: isPressed ? [customWhite, .white] : [.white, customWhite]), startPoint: .topLeading, endPoint: .bottomTrailing)
                Circle()
                    .stroke(Color.clear, lineWidth: 10)
                    .shadow(color: isPressed ? Color.white : customWhite, radius: 3, x: -5, y: -5)
                Circle()
                    .stroke(Color.clear, lineWidth: 10)
                    .shadow(color: isPressed ? customWhite : Color.white, radius: 3, x: 3, y: 3)
            }
        )
        .clipShape(Circle())
        .overlay(
            Circle()
                .trim(from: isTapped ? 0.00001 : 1, to: 1)
                .stroke(LinearGradient(gradient: Gradient(colors: [.purple, .blue]), startPoint: .topLeading, endPoint: .bottomTrailing), style: StrokeStyle(lineWidth: 5, lineCap: .round))
                .frame(width: 88, height: 88)
                .rotationEffect(Angle(degrees: 90))
                .rotation3DEffect(Angle(degrees: 180), axis: (x: 1, y: 0, z: 0))
                .shadow(color: Color.purple.opacity(0.3), radius: 5, x: 3, y: 3)
                .animation(.easeInOut)
        )
        .shadow(color: isPressed ? customWhite : .white, radius: 20, x: -20, y: -20)
        .shadow(color: isPressed ? .white : customWhite, radius: 20, x: 20, y: 20)
        .scaleEffect(isTapped ? 1.2 : 1)
        .gesture(
            LongPressGesture()
                .updating($isTapped) { currentState, gestureState, transaction in
                    gestureState = currentState
                }
                .onEnded({ value in
                    isPressed.toggle()
                })
        )
    }
}
  • 지문 온/오프 이미지를 ZStack 내의 겹친 상태로 표현
  • opacity를 통해 현재 제스처 상태에 따라서 표현 여부 결정
  • 오버레이를 통해 원형 버튼 컴포넌트 위에 trim으로 테두리르 자른 원주 표현 → 제스처 상태에 따라서 from 값이 변경, 애니메이션을 통해 차오르는 듯한 이펙트

구현 화면

SwiftUI가 UIKit보다 오프셋, 애니메이션, 제스처 사용이 보다 편리하다보니 화려한 애니메이션 또한 구현이 쉬운 것 같다. ZStack 등 겹친 이미지, 그레디언트 역시 보다 편리하니 말이다.

profile
JUST DO IT
post-custom-banner

0개의 댓글