기어가더라도 제대로

[SwiftUI-기초] MVVM과 SwiftUI 근데 Combine을 곁들인 본문

SwiftUI - 기초

[SwiftUI-기초] MVVM과 SwiftUI 근데 Combine을 곁들인

Damagucci-juice 2025. 2. 12. 22:40
현재 진행중인 프로젝트가 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

 

Comments