본문 바로가기
iOS

[Swift] 반려동물 일기 만들기[인스타그램 카메라 클론 1] - 3

by 워뇨옹2 2025. 7. 14.
728x90
반응형

벌써 아리가 세상 떠난지도 일주일이 넘게 지나버렸네요.

빨리 완성해서 두리와의 이야기라도

많이 남기고 싶습니다!

왼쪽이 아리, 오른쪽이 두리

카메라 기능 추가하기

제가 원하는 앱의 목적은 그 상황상황마다

앱을 실행하고 곧장 현재의 모습을 기록하는 것이라고

언급했었어요!

 

그러다보니 추후에 넣을까 고민했던 카메라 기능이었지만,

프로토타입에 추가하는게 맞다고 판단하기도 했고,

제가 카메라 도메인 회사에서 일했었던지라

빼먹자니 또 아쉽더라구요 ㅎㅎㅎ

그래서 카메라를 지금 당장 추가하기로 했습니다.

 

또, 이 스토리라는 개념 자체는 인스타그램에서

영향을 많이 받았기 때문에

인스타그램 카메라 UI를 클론하는걸로 

시작해서 필요하거나 필요없는 부분들을

적절히 가감하기로 했습니다.

 

먼저 인스타그램의 카메라는 어떻게 생겼는지 볼까요?

카메라 뷰
앨범 뷰

 

 

인스타를 안해서 몰랐는데,

정말 심플하더라구요!

 

저는 여기서 하단 탭바, 카메라 피드 왼쪽에 위치한

툴바는 사용하지 않을거에요.

 

또 동영상에서 볼 수 있듯이 슬라이드로 

피드에서 카메라 피드로, 카메라 피드에서

앨범으로 화면이 트랜지션 되는걸 볼 수 있어요.

 

 

오늘은 이 부분을 중점으로 구현을 해보겠습니다.

드래그 트랜지션

 

먼저 결과부터 보겠습니다!

 

간단한 컬러 목업으로 화면을 구성했고

트랜지션을 만드는 것에 초점을 두고

작업했습니다.

 

드래그 offset에 따라 parallex를 적절하게 추가하고,

opacity를 조절해서 작동이 어색하지 않게

만들었습니다.

 

그럼 각 파일별로 코드를 어떻게 작성했는지 확인해볼게요.

ContentView

import SwiftUI

struct ContentView: View {
	...
    @State private var isNewStoryPresented: Bool = false

    @State private var dragOffset: CGFloat = 0
    private let screenWidth: CGFloat = UIScreen.main.bounds.width
    private var opacity: CGFloat {
        return abs(dragOffset / screenWidth) * 0.8 + 0.2
    }

    var body: some View {
        ZStack {
            // 💠 배경 - 중앙 고정, 패럴렉스 효과
            NavigationStack {
                CameraFeedView {
                    hideNewStoryView()
                }
            }
            .overlay {
                Color.black.opacity(1.0 - opacity)
            }
            .offset(x: dragOffset * (1 / 3) - screenWidth * (1 / 3))

            // 🌟 전경 뷰
            NavigationStack {
                ... MainViews in TabView
            }
            .offset(x: dragOffset)
        }
        .gesture(
            !isNewStoryPresented ? DragGesture()
                .onChanged { value in
                    // ✅ 음수 스와이프 금지
                    if isNewStoryPresented {
                        return
                    } else {
                        dragOffset = max(0, value.translation.width)
                    }
                    
                }
                .onEnded { value in
                    if self.isNewStoryPresented {
                        return
                    } else {
                        if value.predictedEndTranslation.width > screenWidth / 2 {
                            // ✅ 충분히 드래그했으면 끝까지
                            showNewStoryView()
                        } else {
                            // ✅ 복원
                            hideNewStoryView()
                        }
                    }
                }
            : nil
        )
    }
    
    func showNewStoryView() {
        withAnimation {
            self.dragOffset = screenWidth
            self.isNewStoryPresented = true
        }
    }
    
    func hideNewStoryView() {
        withAnimation {
            self.dragOffset = 0
            self.isNewStoryPresented = false
        }
    }
}

CameraFeedView

import SwiftUI

struct CameraFeedView: View {
    let onDismiss: () -> Void
    
    private let screenHeight: CGFloat = UIScreen.main.bounds.height
    @State private var dragOffset: CGFloat = 0
    @State private var isAlbumPresented: Bool = false
    private var opacity: CGFloat {
        return abs(dragOffset / screenHeight) * 0.8 + 0.2
    }
    var body: some View {
        ZStack {
            VStack {
                HStack {
                    Text("Header")
                        .font(.largeTitle)
                }
                ScrollView {
                    LazyVGrid(columns: [.init(.flexible()), .init(.flexible()), .init(.flexible())]) {
                        ForEach(0...60, id: \.self) { index in
                            Color.gray
                                .aspectRatio(1, contentMode: .fit)
                                .cornerRadius(8)
                        }
                    }
                }
            }
            .opacity(opacity)
            .offset(y: (dragOffset * (1 / 3) + screenHeight * (1 / 3)))
            VStack {
                ZStack {
                    // CameraFeedView
                    Color.gray.opacity(0.7)
                        .cornerRadius(16)
                    VStack {
                        HStack {
                            Image(systemName: "xmark")
                                .onTapGesture {
                                    onDismiss()
                                }
                            Spacer()
                            Image(systemName: "bolt.fill")
                            Spacer()
                            Image(systemName: "gearshape.fill")
                        }
                        .foregroundStyle(.primary)
                        .font(.title)
                        .padding()
                        Spacer()
                        ZStack {
                            Circle()
                                .fill(.white)
                                .frame(width: 70, height: 70)
                        }
                        .padding(.bottom, 32)
                    }
                }
                HStack {
                    RoundedRectangle(cornerRadius: 8)
                        .fill()
                        .frame(width: 34, height: 34)
                        .overlay {
                            Image("aari")
                                .resizable()
                                .scaledToFill()
                                .frame(width: 31, height: 31)
                                .cornerRadius(8)
                                .contentShape(Rectangle())
                                .clipped()
                        }
                        .onTapGesture {
                            showAlbum()
                        }
                    Spacer()
                    Circle()
                        .fill(.placeholder)
                        .frame(width: 44, height: 44)
                        .overlay {
                            Image(systemName: "arrow.triangle.2.circlepath")
                                .font(.title)
                                .fontWeight(.semibold)
                        }
                }
                .padding(.exceptBottom)
            }
            .background(.black)
            .offset(y: dragOffset)
        }
        .gesture(
            DragGesture()
                .onChanged { value in
                    print(value.translation.height)
                    // ✅ 음수 스와이프 금지
                    if isAlbumPresented {
                        dragOffset = -screenHeight + value.translation.height
                    } else {
                        dragOffset = min(0, value.translation.height)
                    }
                    
                }
                .onEnded { value in
                    if self.isAlbumPresented {
                        if value.predictedEndTranslation.height > screenHeight / 2 {
                            // ✅ 충분히 드래그했으면 끝까지
                            hideAlbum()
                        } else {
                            // ✅ 복원
                            showAlbum()
                        }
                    } else {
                        if value.predictedEndTranslation.height < -screenHeight / 2 {
                            // ✅ 충분히 드래그했으면 끝까지
                            showAlbum()
                        } else {
                            // ✅ 복원
                            hideAlbum()
                        }
                    }
                }
        )
    }
    
    func showAlbum() {
        withAnimation(.spring()) {
            dragOffset = -screenHeight
            self.isAlbumPresented = true
        }
    }
    
    func hideAlbum() {
        withAnimation(.spring()) {
            dragOffset = 0
            self.isAlbumPresented = false
        }
    }
}

코드 분석

먼저 이 트랜지션의 포인트는 ZStack을 사용해서 

View의 offset을 조작함으로 네비게이션처럼

작동하게 만든겁니다.

 

사실 이게 정석인지 야매인지는 모르겠지만,

뭐 잘 작동하니 정석이라고 믿어보겠습니다 ㅋㅋ

DragGesture

드래그 제스처 코드를 한 번 볼게요.

먼저 vertical 드래그 제스처 상황입니다.

.gesture(
    DragGesture()
        .onChanged { value in
            print(value.translation.height)
            // ✅ 음수 스와이프 금지
            if isAlbumPresented {
                dragOffset = -screenHeight + value.translation.height
            } else {
                dragOffset = min(0, value.translation.height)
            }

        }
        .onEnded { value in
            if self.isAlbumPresented {
                if value.predictedEndTranslation.height > screenHeight / 2 {
                    // ✅ 충분히 드래그했으면 끝까지
                    hideAlbum()
                } else {
                    // ✅ 복원
                    showAlbum()
                }
            } else {
                if value.predictedEndTranslation.height < -screenHeight / 2 {
                    // ✅ 충분히 드래그했으면 끝까지
                    showAlbum()
                } else {
                    // ✅ 복원
                    hideAlbum()
                }
            }
        }
)

func showAlbum() {
    withAnimation(.spring()) {
        dragOffset = -screenHeight
        self.isAlbumPresented = true
    }
}

func hideAlbum() {
    withAnimation(.spring()) {
        dragOffset = 0
        self.isAlbumPresented = false
    }
}

다음은 horizontal 상황의 코드입니다.

.gesture(
    !isNewStoryPresented ? DragGesture()
        .onChanged { value in
            // ✅ 음수 스와이프 금지
            if isNewStoryPresented {
                return
            } else {
                dragOffset = max(0, value.translation.width)
            }

        }
        .onEnded { value in
            if self.isNewStoryPresented {
                return
            } else {
                if value.predictedEndTranslation.width > screenWidth / 2 {
                    // ✅ 충분히 드래그했으면 끝까지
                    showNewStoryView()
                } else {
                    // ✅ 복원
                    hideNewStoryView()
                }
            }
        }
    : nil
)

func showNewStoryView() {
    withAnimation {
        self.dragOffset = screenWidth
        self.isNewStoryPresented = true
    }
}

func hideNewStoryView() {
    withAnimation {
        self.dragOffset = 0
        self.isNewStoryPresented = false
    }
}

알아둬야할 것은 드래그 제스처를 할 때 translation

값의 부호입니다.

 

vertical 제스처를 할 때는

아래에서 위로 -> -방향

위에서 아래로 -> +방향

 

horizontal 제스처는

왼쪽에서 오른쪽으로 -> +방향

오른쪽에서 왼쪽으로 -> -방향

 

위와 같이 책정됩니다.

따라서 부호를 잘 생각하고 

코드를 작성하면 됩니다.

 

또 predictedENdTranslation이라는 값을

확인할 수 있는데, 이건 현재 드래그 제스처의

속도를 계산하여 현재 힘으로 얼마나

드래그 될 지 예상한 값을 나타냅니다.

 

우리가 스크롤 뷰를 한 번 스윽 하고

스크롤 하면 손을 떼자마자 멈추는게 아니라,

속도가 점점 줄어들면서 좀 더

스크롤 되는거 다들 아시죠?

 

그 멈추는 포인트가 바로 예측된 EndPoint입니다.

그 값을 토대로 화면을 변경해주고 있습니다.

 

위 제스처를 복붙만 해도 드래그 트랜지션 자체는 완성!

패럴렉스 효과

다음은 패럴렉스 효과인데요.

 

웹사이트에서 많이 사용되는데,

스크롤을 하다보면 메인 컨텐츠 자체는

스크롤이 되는데 뒤에 배경들은 뭔가

천천히 가거나 빠르게 가는걸 본 경험이 

있을거에요.

 

그걸 패럴렉스 애니메이션이라고 지칭합니다.

 

인스타그램에서도 피드에서 카메라 피드로 넘어갈 때,

선형으로 움직이는게 아니라,

 

피드는 손가락 위치에 맞게 움직이고

뒤따라 오는 카메라 피드는 조금 천천히

움직이더라구요.

 

좀 디테일하긴 하지만, 선형으로 움직이는 것보다

어색하지 않고 앱이 풍부해보여 추가하기로 했습니다.

 

코드는 다음과 같아요.

ZStack {
    FrontwardContent()
    .offset(y: dragOffset)
	
    BackwardContent()
    .offset(y: (dragOffset * (1 / 3) + screenHeight * (1 / 3)))
}

저는 1/3로 설정했지만,

개인 취향에 맞게 조절하면 됩니다.

 

여기에 opacity까지 조절해주면 

애니메이션이 훨씬 풍부해 보일거에요.

    private var opacity: CGFloat {
        return abs(dragOffset / screenHeight) * 0.8 + 0.2
    }

 

오늘은 이렇게 해서 드래그로

화면을 이동하는 트랜지션 효과를 만들어봤습니다.

 

다음에는 AVFoundation을 사용해서 실제 카메라뷰를

구현하고, 간단한 색상 필터를 구현하는 방법까지 포스팅해볼게요.

728x90
반응형