기어가더라도 제대로
[SwiftUI-기초] MVVM과 SwiftUI 근데 Combine을 곁들인 본문
현재 진행중인 프로젝트가 UIKit 베이스에 새로 추가되는 화면은 SwiftUI로 구현을 하고 있는데, 이 때 가져갈만한 UI 패턴을 뭘로하기가 적당할까 고민이 들었다. UIKit으로만 이루어진 프로젝트라면, Input-Output 구조를 가진 MVVM을 사용했을텐데, 새로운 SwiftUI 환경에서 적합하게 사용하기가 어려웠다. (@Published 프로퍼티 래퍼를 어떻게 해야 좋단말인가..) SwiftUI는 상태의 변화에 집중하는 시스템이니까 State를 가지고 그 상태를 변화를 감시하는 구조를 찾아 정리한다.
만드려는 것
간단한 카운터 앱
- + 버튼을 누르면 1 더하기
- - 버튼을 누르면 1 빼기
ViewModel 구현
- ViewModel을 해도 어떤 뷰모델을 사용하겠다 명확하게 프로토콜로 인터페이스를 구성해준다
- Action은 외부의 행동에 대응
- State는 그에 대한 상태값의 변화를 추적
import Foundation
import Combine
protocol ViewModelType: ObservableObject {
associatedtype Action
associatedtype State
var state: State { get }
func action(_ action: Action)
}
- 구현체 선언
import Foundation
import Combine
final class CounterViewModel: ViewModelType {
// MARK: - Types
enum State {
}
enum Action {
}
// MARK: - Properties
@Published var state: State
// MARK: - Initializer
init() {
}
// MARK: - Action
func action(_ action: Action) {
}
}
- State 정의해서 Count에 표출할 값을 정의
enum State {
case count(Int)
}
- Action에서는 뷰에 더하기, 빼기 버튼 두가지가 있으니 선언
- 만약 상태변화를 추적하고 싶다면 viewDidAppear 같은 것을 추가해도 무방
enum Action {
case onTapAddButton
case onTapSubtractButton
}
- 위 Action에 따라서 action(_ Action) 함수를 구현
func action(_ action: Action) {
switch action {
case .onTapAddButton:
state = .count(getCurrnetCount() + 1)
case .onTapSubtractButton:
state = .count(getCurrnetCount() - 1)
}
}
private func getCurrnetCount() -> Int {
guard case let .count(int) = state else { return 0 }
return int
}
- initializer에서 state에 초기값 제공
// MARK: - Initializer
init() {
state = .count(0)
}
- 완성된 CounterViewModel
import Foundation
import Combine
final class CounterViewModel: ViewModelType {
// MARK: - Types
enum State {
case count(Int)
}
enum Action {
case onTapAddButton
case onTapSubtractButton
}
// MARK: - Properties
@Published var state: State
// MARK: - Initializer
init() {
state = .count(0)
}
// MARK: - Action
func action(_ action: Action) {
switch action {
case .onTapAddButton:
state = .count(getCurrnetCount() + 1)
case .onTapSubtractButton:
state = .count(getCurrnetCount() - 1)
}
}
private func getCurrnetCount() -> Int {
guard case let .count(int) = state else { return 0 }
return int
}
}
View 구현
- CounterView의 초기 구성
import SwiftUI
struct CounterView: View {
@ObservedObject var viewModel: CounterViewModel
var body: some View {
Text("Hello, World!")
}
}
#Preview {
@StateObject var viewModel = CounterViewModel()
CounterView(viewModel: viewModel)
}
- body 부문 편집
var body: some View {
switch viewModel.state {
case .count(let int):
VStack(spacing: 20) {
numberView(int)
HStack(spacing: 50) {
subtractButton()
addButton()
}
}
}
}
- 각각의 뷰를 선언
@ViewBuilder
private func numberView(_ number: Int) -> some View {
Text("\(number)")
.font(.title)
}
@ViewBuilder
private func subtractButton() -> some View {
Button("-") {
viewModel.action(.onTapSubtractButton)
}
.font(.largeTitle)
}
@ViewBuilder
private func addButton() -> some View {
Button("+") {
viewModel.action(.onTapAddButton)
}
.font(.largeTitle)
}
Alert을 구현
만약 숫자가 0보다 작아졌을 때 .alert을 띄우고 싶다면 코드를 어떻게 해야할까?
- 열거형 State를 Struct로 변환
- State는 열거형? 구조체?
- enum이 좋을 때: 상태값이 배타적인 경우, ex) downloadSuccess, downloadFailure, isLoding
- struct가 좋을 때: 확장성 있게 여러 상태값을 관리해야하는 경우
// MARK: - CounterViewModel
struct State {
var count: Int
var alertItem: AlertItem? // `nil`이면 Alert 없음
}
// Alert 상태를 관리하는 구조체 추가
struct AlertItem: Identifiable {
let id = UUID()
let title: String
let message: String
}
- initializer 변환
init() {
state = State(count: 0, alertItem: nil)
}
- action 함수를 struct를 사용하기 알맞게 변환
// MARK: - Action
func action(_ action: Action) {
switch action {
case .onTapAddButton:
state.count += 1
case .onTapSubtractButton:
if state.count - 1 < 0 {
state.alertItem = AlertItem(
title: "에러",
message: "카운트는 0 이하 일 수 없습니다."
)
} else {
state.count -= 1
}
case .onAlertDismiss:
state.alertItem = nil
}
}
- CounterView에서 .alert(item)으로 구현
var body: some View {
VStack(spacing: 20) {
numberView(viewModel.state.count)
HStack(spacing: 50) {
subtractButton()
addButton()
}
}
.alert(item: $viewModel.state.alertItem) { alertItem in
Alert(
title: Text(alertItem.title),
message: Text(alertItem.message),
dismissButton: .default(Text("OK")) {
viewModel.action(.onAlertDismiss)
}
)
}
}
- 완료 화면
전체 코드
https://github.com/Damagucci-Juice/ExSwiftUIMVVM
GitHub - Damagucci-Juice/ExSwiftUIMVVM
Contribute to Damagucci-Juice/ExSwiftUIMVVM development by creating an account on GitHub.
github.com
참조
https://ios-development.tistory.com/1128
'SwiftUI - 기초' 카테고리의 다른 글
[SwiftUI-기초] NavigationStack 알아보기 (0) | 2025.01.28 |
---|---|
[SwiftUI-기초] @Published 프로퍼티의 데이터 동기화 하는 방법 (0) | 2025.01.24 |
[SwiftUI-기초] NavigationTitle 수동으로 inline 만들기(with. ToolbarItem) (0) | 2025.01.23 |
[SwiftUI-기초] 프로젝트에서 TabView 두개 사용하기(Multiple TabView) (0) | 2025.01.22 |
[SwiftUI-기초] 수직 확장 되는 텍스트 필드, TextField, TextEditor (0) | 2025.01.20 |
Comments