본문 바로가기

iOS/SwiftUI

SwiftUI와 상태관리

Ref)

https://pilgwon.github.io/post/episode-65-swiftui-and-state-management-part-1

 

[번역] Point-Free #65 SwiftUI와 상태 관리 - 파트 1

거대하고 복잡한 아키텍쳐를 만드는 일엔 어떤 장애물이 도사리고 있을까요? 오늘은 SwiftUI로 앱을 만들어보면서 애플은 그 장애물을 어떻게 극복했는지 알아보려고 합니다.

pilgwon.github.io

https://pilgwon.github.io/post/episode-66-swiftui-and-state-management-part-2

 

[번역] Point-Free #66 SwiftUI와 상태 관리 - 파트 2

오늘은 파트 1에서 만든 어플리케이션에 약간의 사이드 이펙트와 화면을 추가해서 어플리케이션을 완성해봅시다. 그 후에

pilgwon.github.io

https://pilgwon.github.io/post/episode-67-swiftui-and-state-management-part-3

 

[번역] Point-Free #67 SwiftUI와 상태 관리 - 파트 3

지난 두 에피소드에서 SwiftUI 어플리케이션을 만들면서 이런 생각이 드셨을 것입니다.

pilgwon.github.io

(본 포스트는 위에 블로그를 참고하여 코드를 새로운 버전으로 리팩토링한 글입니다.)

 

 

 

완성화면

 

네비게이션 뷰

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: EmptyView()) {
                    Text("Counter demo")
                }
                
                NavigationLink(destination: EmptyView()) {
                    Text("Favorite primes")
                }
            }
            .navigationTitle("State management")
        }
    }
}

import PlaygroundSupport

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())

가장 먼저 네비게이션 화면을 만들어봤으며 원 글과 같이 Playground에서 코드를 작성하고 실행했다.

NavigationView에 타이틀을 달기 위해서는 .navigationTitle을 사용하면 되는데 이는 NavigationView 안 최상단 즉, 위 코드에서는 List에 달아서 사용해야 된다.

또 각각의 뷰는 아직 구성 전이므로 임시로 EmptyView()를 사용하였다.

 

카운터 뷰

- UI 구성하기

struct CounterView: View {
    var body: some View {
        VStack {
            HStack {
                Button(action: {}) {
                    Text("-")
                }
                
                Text("0")
                
                Button(action: {}) {
                    Text("+")
                }
            }
            
            Button(action: {}) {
                Text("Is this prime?")
            }
            
            Button(action: {}) {
                Text("What is the 0th prime?")
            }
        }
        .navigationTitle("Counter demo")
    }
}

버튼에 action을 구현하지 않은 카운터 뷰의 UI만 간단한게 구성해봤다.

이제 새로운 뷰 구조체를 만들었으니 기존에 있던 NavigationView의 카운터 뷰 destination으로 설정하고 실행하면 잘 동작되는 것을 확인할 수 있다.

더보기

NavigationLink(destination: CounterView()) { // 👈 CounterView() 추가

    Text("Counter demo")

}

 

// ContentView.swift

 

- 기본 기능 구현하기

struct CounterView: View {
    @State private var count = 0 // 👈 현재 카운트 숫자
    
    var body: some View {
        VStack {
            HStack {
                Button(action: { count -= 1 }) { // 👈 현재 카운트 숫자 - 1
                    Text("-")
                }
                
                Text("\(count)") // 👈 현재 카운트 숫자
                
                Button(action: { count += 1 }) { // 👈 현재 카운트 숫자 + 1
                    Text("+")
                }
            }
            
            Button(action: {}) {
                Text("Is this prime?")
            }
            
            Button(action: {}) {
                Text("What is the \(ordinal(count)) prime?")
            }
        }
        .navigationTitle("Counter demo")
    }
    
    // 👇 1st, 2nd, 3th를 표시하기 위한 함수 추가
    private func ordinal(_ n: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .ordinal
        return formatter.string(for: n) ?? ""
    }
}

이제 UI에 동작을 구현해보겠습니다. SwiftUI는 State 프로퍼티 래퍼를 통해 변수를 변경하고 뷰를 다시 그린다.

위 코드에서 -나 +버튼을 클릭하면 count값이 변경되어 뷰를 다시 그리고 Text에 변경된 값이 표시된다.

 

카운터 화면 상태 유지하기

위 화면은 문제없이 동작해 보이지만 카운터 뷰를 나갔다가 돌아오면 CounterView가 다시 생성되어 count가 다시 0부터 시작되는 문제가 있습니다. 이제 값 부분을 따로 관리하여 count를 유지하는 방법을 알아봅시다.

class AppState: ObservableObject {
    @Published var count: Int = 0
}

가장 먼저 ObservableObject를 준수하는 클래스를 만들어 줍시다. 그리고 저장할 값인 count 변수를 생성하고 값 변화를 감지하여 뷰에 알려주기 위해 @Published 프로퍼티 래퍼를 사용하여 감싸줍니다.

(이에 대한 자세한 설명은 원문을 참조해 주시길 바랍니다!)

 

그리고 기존 CounterView를 수정해 줍니다.

struct CounterView: View {
    @ObservedObject var state: AppState // 👈 기존 변수 count를 AppState로 변경
    
    var body: some View {
        VStack {
            HStack {
                Button(action: { state.count -= 1 }) { // 👈 이제 AppState의 count의 값을 가리킨다.
                    Text("-")
                }
                
                Text("\(state.count)") // 👈
                
                Button(action: { state.count += 1 }) { // 👈
                    Text("+")
                }
            }
            
            Button(action: {}) {
                Text("Is this prime?")
            }
            
            Button(action: {}) {
                Text("What is the \(ordinal(state.count)) prime?") // 👈
            }
        }
        .navigationTitle("Counter demo")
    }
    
    private func ordinal(_ n: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .ordinal
        return formatter.string(for: n) ?? ""
    }
}

 

그 다음 CounterView가 AppState를 주입 받아야하기 때문에 ContentView도 수정해준다.

struct ContentView: View {
    @ObservedObject var state: AppState // 👈 ContentView도 AppState를 주입받는다.
    
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: CounterView(state: state)) { // 👈 CounterView에 state를 주입해 준다.
                    Text("Counter demo")
                }

                NavigationLink(destination: EmptyView()) {
                    Text("Favorite primes")
                }
            }
            .navigationTitle("State management")
        }
    }
}

import PlaygroundSupport

PlaygroundPage.current.liveView = UIHostingController(rootView: 
    ContentView(state: AppState()) // 👈 ContentView에 AppState를 생성하여 주입해준다.
)

이제 카운터뷰의 값은 화면이 바뀌어도 상태를 유지하여 뷰가 다시 생성되어도 변경된 값을 가지고 있을 수 있다.

 

 

소수 확인 모달

SwiftUI는 모달을 띄위기 위한 여러 메소드들이 있는데 그 중 .sheet를 사용하여 소수 확인 모달을 구현해 보자

struct CounterView: View {
    @ObservedObject var state: AppState
    @State private var isPrimeModalShown: Bool = false // 👈 소수 확인 모달을 띄울 트리거가 되는 값
    
    var body: some View {
        VStack {
            HStack {
                Button(action: { state.count -= 1 }) {
                    Text("-")
                }
                
                Text("\(state.count)")
                
                Button(action: { state.count += 1 }) {
                    Text("+")
                }
            }
            
            Button(action: { isPrimeModalShown = true }) { // 👈 버튼을 클릭하면 변수를 true로 변경시켜 모달을 띄운다.
                Text("Is this prime?")
            }
            .sheet(isPresented: $isPrimeModalShown) {
                IsPrimeModalView(state: state)
            }
            // 👆 .sheet의 isPresented에 Binding 변수 값을 사용하여 IsPrimeMadalView를 띄운다.
            
            Button(action: {}) {
                Text("What is the \(ordinal(state.count)) prime?")
            }
        }
        .navigationTitle("Counter demo")
    }
    
    private func ordinal(_ n: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .ordinal
        return formatter.string(for: n) ?? ""
    }
}

소수인지 확인하는 메소드를 작성하고 그에 따라 달라지는 뷰를 구성한다.

struct IsPrimeModalView: View {
    @ObservedObject var state: AppState
    
    var body: some View {
        VStack {
            if isPrime(state.count) {
                Text("\(state.count) is prime 🎉")
                
                Button(action: {}) {
                    Text("Save/remove to/from favorite primes")
                }
            } else {
                Text("\(state.count) is not prime :(")
            }
        }
    }
    
    private func isPrime(_ p: Int) -> Bool {
        if p <= 1 { return false } // 1은 소수가 아니다.
        if p <= 3 { return true } // 2, 3은 무조건 소수다.
        for i in 2...Int(sqrtf(Float(p))) {
            if p % i == 0 { return false }
        }
        return true
    }
}

 

 

 

소수 확인 모달UI는 완성했으니 소수를 저장하는 로직을 구현해보겠습니다.

먼저 AppState 클래스에 소수를 저장할 배열 변수를 추가합니다.

class AppState: ObservableObject {
    @Published var count: Int = 0
    @Published var favoritePrimes: [Int] = [] // 👈 배열 변수 추가
    
    // 👇 현재 숫자를 저장했는지 확인하는 변수 추가
    var hasFavoritePrime: Bool {
        favoritePrimes.contains(count)
    }
}

 

기존 모달 뷰에서 저장한 값인지에 따라 달라지는 뷰를 다시 구성해 보고 기능도 추가해 봅시다.

...

if isPrime(state.count) {
    Text("\(state.count) is prime 🎉")
    
    // 👇 해당 숫자를 이미 저장했을 경우 삭제하고 아닐경우 저장할 수 있는 뷰와 기능을 추가한다.
    if state.hasFavoritePrime {
        Button(action: { state.favoritePrimes.removeAll { $0 == state.count } }) {
            Text("Remove from favorite primes")
        }
    } else {
        Button(action: { state.favoritePrimes.append(state.count) }) {
            Text("Save to favorite primes")
        }
    }
} else {
    Text("\(state.count) is not prime :(")
}

...
// IsPrimeModalView.swift

 

n번째 소수 찾기 ( pass )

즐겨찾기 뷰

struct FavoritePrimes: View {
    @ObservedObject var state: AppState // 👈 즐겨찾기 뷰 역시 AppState를 주입 받는다.
    
    var body: some View {
    	// 👇 List와 ForEach로 저장한 소수 목록을 보여준다.
        List {
            ForEach(state.favoritePrimes, id: \.self) { prime in
                Text("\(prime)")
            }
            // 👇 onDelete 메소드를 통해 각 리스트를 제거하는 기능을 쉽게 추가할 수 있다.
            .onDelete { indexSet in
                for index in indexSet {
                    state.favoritePrimes.remove(at: index)
                }
            }
        }
        .navigationBarTitle(Text("Favorite Primes"))
    }
}
struct ContentView: View {
    @ObservedObject var state: AppState
    
    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: CounterView(state: state)) {
                    Text("Counter demo")
                }

                NavigationLink(destination: FavoritePrimes(state: state)) { // 👈 destination을 즐겨찾기 뷰로 생성
                    Text("Favorite primes")
                }
            }
            .navigationTitle("State management")
        }
    }
}

즐겨찾기 뷰 구조체를 만들었다면 ContentView로 돌아와서 NavigationLink의 destination에 추가하여 뷰 전환을 한다.

이제 카운터 뷰에서 저장한 소수들을 즐겨찾기 뷰에서 리스트로 확인 할 수 있고 삭제할 수도 있게 되었다.

 

 

마무리

여기까지 기본적인 기능이 있는 앱을 만들면서 기본적인 기능을 만들어봤다. 위 링크의 part-1과 part-2에 해당하는 내용이고 part-3은 SwiftUI의 문제점등에 대한 내용이여서 여기에서는 추가하지 않았지만 좋은 내용이니 한번 확인해 보는 것도 좋을 것같다. 이 프로젝트를 진행하면서 SwiftUI의 동작구조나 상태관리등을 간단하게 알아 볼 수 있는 계기가 되었던 것 같다 :)