
들어가며
진행하고 있는 프로젝트에서 토스트 팝업을 띄우게 되었는데, 토스트 팝업을 띄우는게 처음이라 어떻게 해야할 지 감이 안 잡혔습니다. 토스트는 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를 제어하기로 했습니다. toastText와 isToastVisible를 선언해주고 버튼이 눌렸을때 토스트 텍스트를 넣고, 토스트 팝업이 띄워지도록 구현했습니다. 또한, 그 다음 뷰에서도 또 다른 토스트 팝업이 띄워지도록 구현했습니다.
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에서 토스트를 관리할 수 있게 구현했습니다.
구현 영상
