[SwiftUI/TCA] 토스트 팝업 띄우기

양재현·2025년 4월 9일
post-thumbnail

들어가며

진행하고 있는 프로젝트에서 토스트 팝업을 띄우게 되었는데, 토스트 팝업을 띄우는게 처음이라 어떻게 해야할 지 감이 안 잡혔습니다. 토스트는 sheet나 fullscreencover를 이용해서 띄우지 않기 때문에 더 난감했습니다. 그래서 그냥 커스텀 토스트를 만들기로 결심했습니다.

Custom Toast

import SwiftUI

struct Toast: View {
    let text: String
    @Binding var isVisible: Bool
    @State private var toastID = UUID()
        var body: some View {
            if isVisible {
                Text(text)
                    .font(.subheadline)
                    .foregroundColor(.white)
                    .frame(maxWidth: UIScreen.main.bounds.width - 40)
                    .frame(height: 36)
                    .background(
                        RoundedRectangle(cornerRadius: 6)
                            .fill(.black)
                    )
                    .contentShape(Rectangle())
                    .opacity(isVisible ? 1 : 0) 
                    .id(toastID)
                    .onAppear {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                            withAnimation(.easeOut(duration: 1.5)) {
                                isVisible = false
                            }
                        }
                    }
            }
        }
}

토스트 팝업 같은 경우에 앱에서 하나의 UI를 중복으로 사용하기 때문에 모듈화 해두었습니다. 토스트 텍스트 같은 경우에는 외부에서 작성할 수 있게 구성했고, 토스트가 사라지는 상태를 isVisible로 바인딩 했습니다.

토스트 .id()에 UUID()를 넣은 이유는 서로 다른 토스트가 중복으로 띄워졌을때 토스트가 없어지지 않는 이슈를 막기 위해서 입니다.

AppFeature

import ComposableArchitecture
import SwiftUI

@Reducer
struct AppFeature {
    @ObservableState
    struct State: Equatable {
        var path = StackState<Path.State>()
        var toastText: String = ""
        var isToastVisible: Bool = false
    }
    enum Action: BindableAction {
        case binding(BindingAction<State>)
        case path(StackActionOf<Path>)
        case nextViewButtonTapped
        case toastButtonTapped
    }
    var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .nextViewButtonTapped:
                state.path.append(.detail(DetailFeature.State()))
                return .none
                
            case .toastButtonTapped:
                state.toastText = "첫 번째 토스트 입니다."
                state.isToastVisible = true
                return .none
                
            case .path(.element(id: _, .detail(.toastButtonTapped))):
                state.toastText = "두 번째 토스트 입니다."
                state.isToastVisible = true
                return .none
                
            case .binding, .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }
}


extension AppFeature {
    @Reducer
    enum Path {
        case detail(DetailFeature)
    }
}

extension AppFeature.Path.State: Equatable {}

AppFeature는 최상위 피쳐고, 대부분의 앱은 Navigation으로 이루어져있기 때문에 제일 상위 뷰에서 toast를 제어하기로 했습니다. toastTextisToastVisible를 선언해주고 버튼이 눌렸을때 토스트 텍스트를 넣고, 토스트 팝업이 띄워지도록 구현했습니다. 또한, 그 다음 뷰에서도 또 다른 토스트 팝업이 띄워지도록 구현했습니다.

AppView

struct AppView: View {
    @Bindable var store: StoreOf<AppFeature>
    
    var body: some View{
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            VStack {
                Button("다음 뷰로 이동") {
                    store.send(.nextViewButtonTapped)
                }
                Button("토스트 팝업 띄우기") {
                    store.send(.toastButtonTapped)
                }
            }
        } destination: { store in
            switch store.case {
            case .detail(let store):
                DetailView(store: store)
            }
        }
        .overlay(
            Toast(text: store.toastText, isVisible: $store.isToastVisible)
                .padding(.bottom, 12),
            alignment: .bottom
        )
        
    }
}

.overlay(, alignment: .bottom)을 NavigationStack에 위치시킴으로 모든 뷰에서 똑같은 위치에 토스트 팝업이 띄워지도록 구성했습니다.

DetailFeature, VIew

import ComposableArchitecture

@Reducer
struct DetailFeature {
    @ObservableState
    struct State: Equatable {
    }
    
    enum Action {
        case toastButtonTapped
    }
    var body: some ReducerOf<Self>{
        Reduce{ state, action in
            switch action {
            case .toastButtonTapped:
                return .none
            }
            
        }
    }
}

import SwiftUI

struct DetailView: View {
    let store: StoreOf<DetailFeature>
    
    var body: some View{
        Button("토스트 팝업 띄우기") {
            store.send(.toastButtonTapped)
        }
    }
}

DetailFeature에서는 버튼과 toastButtonTapped 액션만 구현해놓고 AppFeature에서 토스트를 관리할 수 있게 구현했습니다.

구현 영상

0개의 댓글