일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 스켈레톤 통합
- 앱 성능 개선
- 360도 뷰어
- Native Module
- React Native
- requirenativecomponent
- 360도 이미지 뷰어
- panorama view
- 스켈레톤 UI
- 360도 이미지
- react
- 라이브러리 없이
- 리액트 네이티브
- 파노라마 뷰
- 3b52.1
- react-native-fast-image
- React-Native
- Skeleton UI
- Privacy manifest
- 네이티브
- Android
- native
- privacyinfo.plist
- boilerplate 제거
- 스플래시스크린
- ios
- launchscreen
- launch screen
- 리액트
- 리엑트 네이티브
- Today
- Total
Neoself의 기술 블로그
SwiftUI에서 Toast와 Popup의 중앙화된 상태 관리 구현하기 본문
모든 앱이든 사용자에게 중요한 정보를 전달하고자 할때는, 팝업과 토스트 UI를 자주 사용합니다. 이러한 UI는 어느 화면에 종속되지 않고, 특정 이벤트가 발생할때를 기준으로 항상 사용자에게 보여야하는 UI인 만큼, 여러 화면에서 이를 표시할 수 있어야 합니다. 이번 게시글에서는 Toast와 Popup의 구성 데이터와 표시 관련 로직을 중앙화하는 과정을 글로 정리해보았습니다.
1. 문제점
처음에는 위 두개 컴포넌트를 필요로 하는 View들에서 직접 렌더 관련 @State 변수를 생성해 개별적으로 관리하는 방식을 사용해왔었습니다.
struct ExampleView: View {
@Environment(\.dismiss) var dismiss
@State private var showLogoutAlert = false
var body: some View {
ZStack{
Color(isDarkMode ? .black : .white)
.edgesIgnoringSafeArea(.all)
VStack{
...
}
if showLogoutAlert {
CustomPopupTwoBtn(
isShowing: $showLogoutAlert,
title: "로그아웃",
message: "정말로 로그아웃 하시겠습니까?",
primaryButtonTitle: "로그아웃",
secondaryButtonTitle: "취소",
primaryAction: {
viewModel.logout()
},
secondaryAction: {}
)
}
}
}
}
허나 이러한 방식은 팝업 및 토스트 컴포넌트를 활용하는 화면 수가 점차 많아짐에 따라 아래 이슈들을 확인할 수 있었습니다.
1.1 Z-index 이슈
struct SomeView: View {
@State private var showPopup = false
var body: some View {
ZStack {
// 메인 콘텐츠
VStack {
// ... 콘텐츠
}
// ContentView(최상단 View)에서 선언한 TabView보다 Z 좌표 기준 아래에 렌더링되는 문제
if showPopup {
CustomPopup(/*...*/)
}
}
}
}
가장 큰 이슈는 바로 Z-index 관련 이슈였습니다. 하단 계층의 뷰에서 팝업 컴포넌트를 띄우는 방식이기 때문에 아무리 ZStack을 통해 Z-index를 부여하여도, 하단 탭바와 같은 상단 계층에 있는 부모 컴포넌트에 의해 가려지는 현상이 있었습니다.
1.2 코드 중복
struct BadExampleView: View {
@State private var firstAlert = false
@State private var secondAlert = false
@State private var thirdAlert = false
var body: some View {
ZStack{
Color(isDarkMode ? .black : .white)
.edgesIgnoringSafeArea(.all)
VStack{
...
}
if firstAlert {
CustomPopupTwoBtn(
isShowing: $firstAlert,
title: "로그아웃",
message: "정말로 로그아웃 하시겠습니까?",
primaryButtonTitle: "로그아웃",
secondaryButtonTitle: "취소",
primaryAction: {
viewModel.logout()
},
secondaryAction: {}
)
}
if secondAlert {
CustomPopupOneBtn(
isShowing: $secondAlert,
title: "로그아웃",
message: "정말로 로그아웃 하시겠습니까?",
primaryButtonTitle: "로그아웃",
primaryAction: {
viewModel.logout()
}
)
}
if thirdAlert {
CustomPopupOneBtn(
isShowing: $thirdAlert,
title: "로그아웃",
message: "정말로 로그아웃 하시겠습니까?",
primaryButtonTitle: "로그아웃",
primaryAction: {
viewModel.logout()
}
)
}
...
}
}
}
다음으로 코드 중복 문제가 있었습니다. 결국 모든 화면마다 표시될 가능성이 있는 토스트 및 팝업을 사전에 선언해놓고, 조건부 렌더를 하는 방식이기 때문에, 위처럼 같은 뷰 컴포넌트에 다수의 팝업 컴포넌트를 선언하는 상황이 빈번하였고, 이는 컴포넌트가 표시되어야 하는 모든 뷰에서 발생하였습니다.
이슈에 대한 해결책을 모색하던 도중, Popup과 Toast 컴포넌트를 구성하는 내용은 텍스트, 버튼 label과 같은 일부 내용을 제외하고는 모두 동일한 레이아웃을 공유하기에, 중앙에서의 관리가 용이할 것이다 라는 가설을 세우고 최상단 뷰에서 이를 관리하는 시스템을 구현해보기로 했습니다.
2. 해결방안
2.1 팝업 및 토스트 구성 데이터 열거형으로 정리
팝업과 토스트, 그중에서 특히 팝업은 컴포넌트 내부에 많은 데이터들이 표시되지만, 결국 이러한 데이터는 특정 케이스별로 정형화될 수 있습니다. 이러한 특성을 활용해 Enums 자료구조를 활용해 팝업 유형을 정의한 후, 상황에 따라 유형만으로 팝업을 띄울 수 있도록 환경을 구축하였습니다.
enum PopupType: Equatable {
case createTodo(String)
case todoFull
case login
var title: String {
switch self {
case .createTodo(let deadline):
return "할일을 \(deadline)에 추가하였습니다."
case .todoFull:
return "할일 초과"
case .login:
return "로그인하기"
}
}
var description: String? {
switch self {
case .todoFull:
return "할일이 초과되었습니다."
case .login:
return "지금 로그인하면,\n 혜택을 받을 수 있어요"
default:
return nil
}
}
}
물론, 팝업 컴포넌트들중에도 상황에 따라 제목 밑에 있는 설명글이 없는 경우도 있었기 때문에, 이러한 데이터는 옵셔널 타입을 반환하도록 해주었습니다.
2.2 구조체를 통한 데이터-액션 연결
이후에 다시 설명하겠지만, Popup과 Toast를 관리하는 최상단 뷰에서는 onChange()를 통해 팝업, 토스트 표시여부를 실시간으로 변경하게 됩니다. 여기서 onChange의 대상이 되기 위해선 Equatable 프로토콜을 준수해야하는데, 제목, 설명문과 같은 텍스트들은 정적인 데이터와 달리 Popup과 Toast에서 호출되는 ()->Void 타입의 action 변수는 클로저를 통해 외부에서 주입받기 때문에 Equatable 프로토콜 준수조건에 맞지 않았습니다.
따라서 action 변수는 Equatable 프로토콜을 채택하지 않는 PopupData 구조체에 정의한 후, PopupComponent 자체는 PopupData를 참조 그리고 ContentView내에서의 onChange 대상은 Equatable을 준수하는 PopupType을 바라보도록 최종 구성하게 되었습니다.
struct CustomPopup: View {
let hidePopup: () -> Void
let popupData: PopupData
var body: some View {
VStack(spacing: 0) {
...
}
}
}
// CustomPopup 컴포넌트에서 사용되는 PopupData
struct PopupData {
let type: PopupType
let action: () -> Void
}
// ContentView에서 onChange 대상이 되는 PopupType
enum PopupType: Equatable {
...
}
struct ContentView: View {
@EnvironmentObject var appState: AppState
@State private var isToastPresent = false
@State private var isPopupPresent = false
var body: some View {
ZStack {
...
}
// Toast 및 Popup 표시여부 변경하는 로직
.onChange(of: appState.currentToast?.type) { _, newToast in
handleToastChange(newToast,in: 3.0)
}
.onChange(of: appState.currentPopup?.type) { _, newPopup in
handlePopupChange(newPopup)
}
}
}
이렇게 컴포넌트 구성 데이터를 정리함에 따라, 각 화면마다 흩어져 있었던 팝업 및 토스트 구성 텍스트 데이터들을 한 곳에서 확인 및 관리할 수 있게 되었습니다.
2.3 Enums와 action으로 구성된 data 구조체를 표시할 수 있도록 Popup, Toast 컴포넌트 변경
다음은 앞서 생성한 type과 action을 인자로 받고 모든 케이스에 적용될 수 있는 커스텀 컴포넌트를 구현하였습니다.
import SwiftUI
import Foundation
struct CustomToast: View {
let toastData: ToastData
var body: some View {
HStack{
Text(toastData.type.text)
.foregroundStyle(.white)
Spacer()
if let action = toastData.action {
Button(action: action ) {
Text(toastData.type.button)
}
.padding(.horizontal,6)
}
}
.padding(.vertical,14)
.padding(.horizontal,18)
.background(RoundedRectangle(cornerRadius: 8)
.fill(.black.opacity(0.7)))
.shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 4)
.padding(16)
}
}
// MARK: - 팝업 컴포넌트
struct CustomPopup: View {
let hidePopup: () -> Void
let popupData: PopupData
var body: some View {
VStack(spacing: 0) {
VStack(spacing:6){
Text(popupData.type.title)
.multilineTextAlignment(.center)
.foregroundColor(.black)
if let description = popupData.type.description {
Text(description)
.multilineTextAlignment(.center)
}
}
.padding(.vertical,20)
if popupData.type.isBtnHorizontal {
horizontalButtonLayer
} else {
verticalButtonLayer
}
}
.padding(.horizontal,12)
.padding(.vertical,8)
.background(RoundedRectangle(cornerRadius:20)
.fill(.white)
)
.padding(.horizontal, 44)
}
// MARK: - 하단 버튼 레이어
private var verticalButtonLayer: some View {
VStack(spacing: 4) {
Button(action: {
popupData.action()
hidePopup()
}) {
Text(popupData.type.primaryButtonText)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.foregroundStyle(.white)
.background(RoundedRectangle(cornerRadius: 8)
.fill(.black)
)
}
Button(action: {
hidePopup()
}) {
Text("닫기")
}
.padding(8)
}
}
private var horizontalButtonLayer: some View {
HStack(spacing: 8) {
Button(action: {
hidePopup()
}) {
Text(popupData.type.secondaryButtonText)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(.black)
.cornerRadius(8)
}
Button(action: {
popupData.action()
hidePopup()
}) {
Text(popupData.type.primaryButtonText)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(.black)
.cornerRadius(8)
}
}
.foregroundStyle(.white)
}
}
2.4 AppState를 통한 팝업 및 토스트 컴포넌트 표시 상태값 관리
이제 SwiftUI에서 해당 컴포넌트가 표시되어야하는 시점을 알 수 있도록 현재 컴포넌트 데이터 상태를 전파하는 로직을 구현하겠습니다. 설령, 팝업이나 토스트 데이터가 nil이 아닐 경우, 채워져 있는 데이터를 즉시 팝업 및 토스트 형태로 표시하게끔 로직을 구성해볼 수 있겠죠.
저는 이를 위해 SwiftUI의 EnvironmentObject를 활용하였습니다. 앱 전역에서 접근 및 변경이 가능하고, 해당 객체 내에서 변경되는 정보들 또한 @Published 래퍼를 통해 SwiftUI 프레임워크에 전파가 가능하기 때문입니다.
class AppState: ObservableObject {
@Published var currentToast: ToastData?
@Published var currentPopup: PopupData?
}
@main
struct ExampleApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
}
}
}
ObservableObject 프로토콜을 적용한 AppState 클래스를 선언해준 후, @main 앱 구조체에서 이를 @StateObject로 생성하여 environmentObject로 주입시켜주면 됩니다.
2.5 ContentView에서 일괄 처리
이제 AppState 환경객체를 추적하며 내부에 있는 팝업&토스트 구성 요소 데이터에 변화가 감지되면, 이에 따라 팝업이나 토스트를 띄우는 핵심 로직을 구현하면 완성됩니다.
struct ContentView: View {
@EnvironmentObject var appState: AppState
@State private var isToastPresent = false
@State private var isPopupPresent = false
var body: some View {
ZStack {
if appState.isLoggedIn || appState.isGuestMode {
VStack{
MainContent()
TabView() // Z-index 문제의 원인이였던 TabView
}
} else {
OnboardingView()
}
// ✅ 항상 최상단에 렌더링되는 Toast/Popup
if let popup = appState.currentPopup {
Color.black
.edgesIgnoringSafeArea(.all)
.opacity(isPopupPresent ? 0.3 : 0.0)
CustomPopup(hidePopup: hidePopup, popupData: popup)
.opacity(isPopupPresent ? 1 : 0)
.offset(y: isPopupPresent ? 0 : -80)
}
if let toast = appState.currentToast {
CustomToast(toastData: toast)
.frame(maxHeight: .infinity, alignment: .top)
.opacity(isToastPresent ? 1 : 0)
.offset(y: isToastPresent ? 0 : -80)
}
}
.onChange(of: appState.currentToast?.type) { handleToastChange($1) }
.onChange(of: appState.currentPopup?.type) { handlePopupChange($1) }
}
private func handlePopupChange(_ newPopup:PopupType?){
if newPopup != nil { isPopupPresent = true }
}
private func hidePopup(){
isPopupPresent = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
appState.removePopup()
}
}
private func handleToastChange(_ newToast: ToastType?, in interval:Double) {
if newToast != nil {
isToastPresent = true
DispatchQueue.main.asyncAfter(deadline: .now() + interval) {
isToastPresent = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
appState.currentToast = nil
}
}
}
}
}
ContentView 최상단에 ZStack을 사용하여 레이어 순서를 명시적으로 제어할 수 있게 한 후, 최상단에 보여져야하는 Popup과 Toast 컴포넌트를 최상단에 배치하여 처음 있던 Z-index 이슈를 해결하였습니다.
2.6 애니메이션 상태값 관리
앞서 설명드렸듯 AppState를 통해 Toast 및 Popup 컴포넌트에 담길 구성 데이터를 AppState 객체 내부에 정의 및 접근하고는 있지만, 컴포넌트의 표시와 사라지는 시점과 직접 연결되어있지는 않습니다.
isToastPresent와 isPopupPresent로 구성된 로컬 상태값이 실제 표시여부를 제어하고 있으며, AppState 내부 변수는 조건부 렌더의 조건 그리고 로컬 상태값이 true로 변경되는 트리거로써 간접적으로 사용되고 있습니다.
이렇게 상태관리를 이원화하게 된 이유는 실제로 데이터가 사라지는 시점과 컴포넌트가 가려지는 시점을 구분해야하기 위함입니다. 만약에 별도 로컬상태값 없이 오롯이 구성 데이터 존재여부로만 컴포넌트 표시상태를 제어하게 된다면 어떻게 될까요?
버튼과 같이 컴포넌트를 닫으려는 이벤트가 호출되면 컴포넌트가 사라지는 트랜지션이 트리거되며, Animatable 상태값의 보간이 시작됩니다. 하지만 이와 동시에 컴포넌트 구성데이터는 nil이 됨에 따라 조건부 렌더 조건에 위배되면서 컴포넌트는 강제로 언마운트 됩니다. 즉 애니메이션을 완료할수 없게 되며 부자연스러운 사용자 경험을 초래하게 되는 것이죠.
따라서 컴포넌트를 닫으려는 이벤트가 호출되면, 먼저 ContentView에서 생성한 로컬 상태값이 변경되면서 애니메이션이 시작되고, 애니메이션 소요시간인 0.5초 뒤에 AppState 내부 구성 데이터를 nil로 변경해주면서, 안정적으로 애니메이션 재생 -> 컴포넌트 구성 데이터 초기화 로직을 구성해준 것입니다. 이렇게 이원화한 로컬 상태값들을 opacity, offset과 같은 View Modifier와 조합해 컴포넌트가 표시되고 사라지는 시점에 fade in/out 효과와 슬라이드 효과를 구현하였습니다.
3. 개선된 팝업 및 토스트 컴포넌트 호출 방식
struct AfterView: View {
@EnvironmentObject var appState: AppState
var body: some View {
// 비즈니스 로직에만 집중
Button("토스트 띄우기") {
appState.currentToast = ToastData(
type: .todoFull,
action: {print("토스트 띄워져요!")}
)
}
}
}
이제 CustomPopup을 직접 뷰에 선언하지 않아도, appState 내부의 currentToast나 currentPopup에 data를 주입시켜주는 것만으로 앱 내 모든 화면에서 Toast와 Popup 컴포넌트를 표시할 수 있게 됩니다. 이로써, 각 뷰에서는 Boilderplate를 대폭 줄일 수 있게 되었으며, 비즈니스 로직 자체에 집중할 수 있게 되었습니다.
위와 같이 Toast와 Popup 컴포넌트의 데이터와 상태를 중앙에서 관리하게 되면서, 아래와 같은 이슈를 해결 및 개선할 수 있었습니다.
- TabView에 컴포넌트가 가려졌던 Z-index 문제를 해결
- 각 뷰에서 반복되었던 상태관리 코드 및 뷰 선언 코드 일괄 제거
- 컴포넌트 구성 데이터와 렌더 관련 로직을 한곳에서 관리할 수 있게 됨에 따라 유지보수성 향상
- 모든 팝업과 토스트가 동일한 애니메이션을 적용하게 되면서 사용자 경험이 일관화됨
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
Keychain+UserDefaults+Combine을 활용해 iOS 로그인 플로우 고도화하기 (1) | 2024.11.07 |
---|---|
Swift 프로토콜 기반 아키텍처로 리팩토링하기 (1) | 2024.10.31 |
XCode에서 Apple Watch 실기기로 디버깅 하기 (2) | 2024.10.27 |
WatchOS에서의 CoreData 사용기 - 데이터 손실 없이 안정적으로 서버에 데이터 전송하기 (1) | 2024.10.25 |
UIKit + MVC 아키텍처 패턴 사용기 (3) | 2024.10.24 |