iOS/SwiftUI

[SwiftUI] ๊ฐ„๋‹จํ•œ ๋ฉ”๋ชจ์•ฑ์„ ๋งŒ๋“ค๋ฉด์„œ ์•Œ์•„๊ฐ€๋ณด์ž ( feat.TCA )

๊ฒฝํ‘ธ 2024. 1. 18. 10:30
๋ฐ˜์‘ํ˜•

SwiftUI๊ฐ€ ์ฒ˜์Œ ๋‚˜์™”์„ ๋•Œ, ์•„์ฃผ ์กฐ๊ธˆ UI์™€ ๊ด€๋ จ๋œ ๋‚ด์šฉ๋“ค์„ ํ•™์Šตํ–ˆ๋˜ ์ ์ด ์žˆ๋‹ค.

์‹œ๊ฐ„์ด ์–ผ๋งˆ๋‚˜ ์ง€๋‚ฌ๋Š”์ง€

์ด์ œ๋Š” ์ฑ„์šฉ ๊ณต๊ณ ๋“ค์„ ๋ณด๋ฉด

SwiftUI์™€ Combine ๊ทธ๋ฆฌ๊ณ  TCA๊ฐ€ ๊ธฐ์ˆ ์Šคํƒ์— ๋“ค์–ด๊ฐ€ ์žˆ๋Š” ๊ฒƒ์„

์ƒ๊ฐ๋ณด๋‹ค ์ž์ฃผ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

๊ทธ๋ ‡๋‹ค. ๋ฏธ๋ค„๋’€๋˜ SwiftUI๋ฅผ ํ•™์Šตํ•ด์•ผํ•  ๋•Œ๋‹ค.

 


 

์šฐ์„ 

๊ณผ๊ฑฐ์— ํ•™์Šตํ–ˆ๋˜ ๊ฒƒ๋“ค ์ค‘์— ๊ธฐ์–ต์— ๋‚จ๋Š” ๊ฒƒ์ด ์ „ํ˜€ ์—†๋‹ค.

์—ญ์‹œ, ์ผ๋‹จ์€ ๋ญ๋ผ๋„ ๋งŒ๋“ค์–ด๋ด์•ผ ๋  ๊ฒƒ ๊ฐ™๋‹ค.

UIkit์„ ํ•™์Šตํ•˜๋ฉด์„œ ๊ฐ„๋‹จํ•œ ๋ฉ”๋ชจ์•ฑ์„ ๋งŒ๋“ค์—ˆ์—ˆ๋Š”๋ฐ

์ด๋ฒˆ์—๋„ ๋งŒ๋“ค์–ด ๋ด์•ผ๊ฒ ๋‹ค.

 

๋ฉ”๋ชจ์•ฑ ๊ตฌ์กฐ

๊ฐ„๋‹จํ•˜๋‹ค.

๊ธฐ๋ณธํ™”๋ฉด

- ์ƒ์„ฑ๋œ ๋ฉ”๋ชจ๋“ค์ด ๋ฆฌ์ŠคํŠธ ํ˜•ํƒœ๋กœ ๋ณด์ด๋Š” ํ™”๋ฉด + ๋ฉ”๋ชจ๋ฅผ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๋Š” SearchBar

์ˆ˜์ • ๋ฐ ์ถ”๊ฐ€ ํ™”๋ฉด

- ๋ฆฌ์ŠคํŠธ์— ์žˆ๋Š” ์•„์ดํ…œ์„ ํด๋ฆญํ•ด์„œ ๋“ค์–ด์˜ค๊ฒŒ๋˜๋ฉด ์ˆ˜์ •

- ์ƒ๋‹จ์˜ Toolbar์—์„œ ์ถ”๊ฐ€ํ•˜๊ธฐ ๋ฒ„ํŠผ์œผ๋กœ ๋“ค์–ด์˜ค๊ฒŒ ๋˜๋ฉด ์ถ”๊ฐ€

 

์•„ํ‚คํ…์ฒ˜ 

 

GitHub - pointfreeco/swift-composable-architecture: A library for building applications in a consistent and understandable way,

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. - GitHub - pointfreeco/swift-composable-architecture: A library for bu...

github.com

TCA๋Š” SwifUI์—์„œ UIKit์˜ MVVM ๋งŒํผ ์ฃผ๋ฅ˜๊ฐ€ ๋œ ์•„ํ‚คํ…์ฒ˜์ธ ๊ฒƒ ๊ฐ™๋‹ค.

๋Œ€์„ธ์—๋Š” ์ด์œ ๊ฐ€ ์žˆ๋‹ค๊ณ  ์ƒ๊ฐํ•œ๋‹ค.

 

์ผ๋‹จ ์‚ฌ์šฉํ•ด๋ณด๋ฉด์„œ ๋Š๋‚€ ๊ฑด UIKit์—์„œ ์‚ฌ์šฉํ–ˆ๋˜ ReactorKit๊ณผ ๊ฑฐ์˜ ์œ ์‚ฌํ•œ ๊ฒƒ ๊ฐ™๋‹ค.

์•„๋‹ˆ, ์ฐจ์ด์ ์„ ๊ทธ๋‹ค์ง€ ๋Š๋ผ์ง€ ๋ชปํ–ˆ๋‹ค.

 

๊ฐ„๋‹จํ•˜๊ฒŒ ์ดํ•ดํ•œ๋Œ€๋กœ ์„ค๋ช…ํ•ด ๋ณด๋ฉด

๋‹จ๋ฐฉํ–ฅ ํ”Œ๋กœ์šฐ์˜ ์•„ํ‚คํ…์ฒ˜๋กœ ์•ก์…˜ - ์ƒํƒœ ๋ณ€๊ฒฝ - UI ๋ณ€๊ฒฝ ์‚ฌ์ดํด๋กœ ์ด๋ฃจ์–ด์ง„๋‹ค๊ณ  ๋ณด๋ฉด ๋œ๋‹ค.

 

[์•„ํ‚คํ…์ฒ˜] ReactorKit์— ๋Œ€ํ•ด์„œ(feat.Flux)

์•ˆ๋…•ํ•˜์„ธ์š”. ์˜ค๋Š˜์€ ReactorKit์— ๋Œ€ํ•ด์„œ ์ •๋ฆฌํ•ด๋ณด๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค. ReactorKit? ReactorKit์˜ ๊นƒํ—ˆ๋ธŒ์— ๋“ค์–ด๊ฐ€ ๋ณด๋ฉด ์†Œ๊ฐœ๊ธ€์˜ ์ฒซ ๋ฌธ์žฅ์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค. ReactorKit is a framework for a reactive and unidirectional Swift ap

pooh-footprints.tistory.com

 

๊ตฌํ˜„

1. ์ผ๋‹จ List์— ๋„ฃ์„ ๋ฉ”๋ชจ ์•„์ดํ…œ์ด ํ•„์š”ํ•˜๋‹ค.

struct Memo: Identifiable, Equatable {
    var id = UUID()
    var title: String
    var description: String
    var date: Date
}

List์˜ ์•„์ดํ…œ์€ Identifiable ํ•ด์•ผ ํ•œ๋‹ค.

 

2. List์˜ Row์— ๋“ค์–ด๊ฐˆ ๋ฉ”๋ชจ๋ทฐ๊ฐ€ ํ•„์š”ํ•˜๋‹ค.

struct MemoView: View {
    
    var memo: Memo
    
    init(memo: Memo) {
        self.memo = memo
    }
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 12) {
                Text(memo.title)
                    .font(.system(size: 18, weight: .bold))
                    .lineLimit(2)
                    .foregroundStyle(.white)
                Text(memo.description)
                    .font(.system(size: 16, weight: .regular))
                    .lineLimit(2)
                    .foregroundStyle(.white)
            }
            .padding()
            .background(.black)
        }
        .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
        .background(.black)
        .cornerRadius(12)
    }
}

์•„์ง UI๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ๋ฐฉ์‹์ด ๋ณต์žกํ•˜๊ณ , ์–ด๋ ต๊ฒŒ ๋Š๊ปด์ง„๋‹ค.

์ฒ˜์Œ MemoView๋ฅผ ๋ฆฌ์ŠคํŠธ์— ์ ์šฉํ–ˆ์„ ๋•Œ, Width๊ฐ€ ํ™”๋ฉด๋„ˆ๋น„์— ์ ˆ๋ฐ˜๋„ ํ‘œ์‹œ๋˜์ง€ ์•Š์•˜์—ˆ๋‹ค.

UIKit์„ ์‚ฌ์šฉํ–ˆ๋˜ ์œ ์ €๋ผ๋ฉด .frame() ์ฝ”๋“œ๋ฅผ ์ž˜ ๋ด๋‘๋ฉด ์ข‹๋‹ค.

์ตœ์†Œ ๋„ˆ๋น„๋ฅผ 0, ์ตœ๋Œ€ ๋„ˆ๋น„๋ฅผ .infinity๋กœ ์„ค์ •ํ•˜๊ฒŒ ๋˜๋ฉด

UIkit์˜ UITableView์—์„œ Cell์— ๊ฝ‰ ์ฐจ๊ฒŒ Constraint๋ฅผ ์ฃผ์—ˆ๋˜ ๊ฒƒ์ฒ˜๋Ÿผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค.

 

3. List์—๋Š” Memo ๋ฐฐ์—ด์„ ์ „๋‹ฌํ•ด์•ผ ํ•œ๋‹ค. ๋”ฐ๋ผ์„œ ์ด Memo ๋ฐฐ์—ด์„ ์ƒํƒœ๋กœ ๊ฐ€์ง€๊ณ  ์žˆ๋Š” Memos Reducer๋ฅผ ๋งŒ๋“ค๋ฉด ๋œ๋‹ค.

struct Memos: Reducer {
    
    struct State: Equatable {
        var memos: [Memo] = []
        var searchedMemos: [Memo] = []
        var selectedMemo: Memo? = nil
    }
    
    enum Action: Equatable {
        case findMemo(_ content: String)
        case addMemo(_ memo: Memo)
        case deleteMemo(_ id: UUID)
        case updateMemo(id: UUID, title: String, description: String)
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .findMemo(let content):
                state.searchedMemos = state.memos.filter { $0.description.contains("\(content)") || $0.title.contains("\(content)") }
                return .none
            case .addMemo(let memo):
                state.memos.append(memo)
                return .none
            case .deleteMemo(let id):
                state.memos = state.memos.filter { $0.id != id }
                return .none
            case .updateMemo(id: let id, title: let title, description: let description):
                if let index = state.memos.firstIndex(where: { $0.id == id }) {
                    state.memos[index].title = title
                    state.memos[index].description = description
                    state.memos[index].date = Date()
                }
                return .none
            }
        }
    }
}

 

State๋ฅผ ๋ณด๋ฉด

ํ™”๋ฉด์— ๋ณด์—ฌ์ค„ Memo ๋ฐฐ์—ด์„ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค. ๋˜, List์˜ Row๋ฅผ ํด๋ฆญํ•ด ์ˆ˜์ •์„ ์›ํ•  ๋•Œ ์‚ฌ์šฉํ•  SelectedMemo ํ”„๋กœํผํ‹ฐ์™€ ๊ฒ€์ƒ‰ ์‹œ ์‚ฌ์šฉํ•  Memo ๋ฐฐ์—ด๋„ ๊ฐ€์ง€๊ณ  ์žˆ๋‹ค. ( Realm์ด๋‚˜ SQLite์™€ ๊ฐ™์€ DB๊ด€๋ จ ์ฝ”๋“œ๊ฐ€ ์—†๊ธฐ ๋•Œ๋ฌธ์— Memo ๋ฐฐ์—ด์—์„œ ํ•„ํ„ฐ๋งํ•ด์„œ ๊ฐ€์ ธ์™€์•ผ ํ•œ๋‹ค. )

1. findMemo(_ content: String) - description์— ํ•ด๋‹น String์„ ํฌํ•จํ•˜๋Š” ๋ฉ”๋ชจ๊ฐ€ ์žˆ๋‹ค๋ฉด searchedMemos์— ์ €์žฅํ•˜๊ธฐ

2. addMemo(_ memo: Memo) - memos ๋ฐฐ์—ด์— ๋ฉ”๋ชจ ์ถ”๊ฐ€

3. deleteMemo(_ id: UUID) - id๊ฐ€ ๋™์ผํ•œ ๋ฉ”๋ชจ๋ฅผ ์ฐพ์•„ ์ œ๊ฑฐ

4. updateMemo(id: UUID, title: String, description: String) - id๊ฐ€ ์ผ์น˜ํ•˜๋Š” ๋ฉ”๋ชจ๋ฅผ ์ฐพ๊ณ  ๋‚ด์šฉ ์—…๋ฐ์ดํŠธ

 

4. ์—ฌ๊ธฐ๊นŒ์ง€ ๊ตฌ์„ฑํ–ˆ๋‹ค๋ฉด MemosView๋งŒ ๊ตฌํ˜„ํ•˜๋ฉด ๋œ๋‹ค.

struct MemosView: View {
    
    let store: StoreOf<Memos>
    @State private var isShowingBottomSheet = false
    @State private var bottomSheetType: BottomSheetType = .add(.fail)
    @State private var searchedText: String = ""
    @State private var isSearching: Bool = false
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            ZStack {
                NavigationStack {
                    List {
                        ForEach(memoList(for: viewStore), id: \.id) { memo in
                            NavigationLink {
                                MemoEditor(memo: memo, memoEditType: .update) { memo in
                                    viewStore.send(.updateMemo(id: memo.id, title: memo.title, description: memo.description))
                                    bottomSheetType = .update(.success)
                                    isShowingBottomSheet.toggle()
                                }
                            } label: {
                                MemoView(memo: memo)
                            }
                        }
                        .onDelete(perform: { indexSet in
                            let index = indexSet[indexSet.startIndex]
                            let id = viewStore.state.memos[index].id
                            viewStore.send(.deleteMemo(id))
                            bottomSheetType = .delete(.success)
                            isShowingBottomSheet.toggle()
                        })
                        .listRowSeparator(.hidden)
                        .listRowBackground(Color.clear)
                    }
                    .listStyle(.grouped)
                    .navigationTitle("๋ฉ”๋ชจ")
                    .toolbar {
                        NavigationLink(destination: {
                            MemoEditor(memo: nil, memoEditType: .add) { memo in
                                viewStore.send(.addMemo(memo))
                                bottomSheetType = .add(.success)
                                isShowingBottomSheet.toggle()
                            }
                        }, label: {
                            Text("์ถ”๊ฐ€ํ•˜๊ธฐ")
                                .font(.system(size: 18, weight: .regular))
                                .foregroundStyle(.black)
                        })
                    }
                }
                .modifier(NavigationBarStyleModifier())
                .searchable(text: $searchedText, placement: .navigationBarDrawer, prompt: "๋ฉ”๋ชจ๋ฅผ ๊ฒ€์ƒ‰ํ•ด๋ณด์„ธ์š”.")
                .onChange(of: searchedText) {
                    isSearching = !searchedText.isEmpty
                    viewStore.send(.findMemo(searchedText))
                }
                
                BottomSheet(isShowing: $isShowingBottomSheet, bottomSheetType: $bottomSheetType)
            }
        }
    }
    
    private func memoList(for viewStore: ViewStore<Memos.State, Memos.Action>) -> [Memo] {
        if isSearching {
            return viewStore.state.searchedMemos
        } else {
            return viewStore.state.memos
        }
    }
}

์ฒ˜์Œ ์ž‘์„ฑํ•ด ๋ด์„œ ๊ทธ๋Ÿฐ์ง€ ์กฐ๊ธˆ์€ ์–ด์„คํผ ๋ณด์ผ ์ˆ˜ ์žˆ๋‹ค. ์•„๋‹ˆ ์–ด์„คํ”„๋‹ค.

์ค‘๊ฐ„์ค‘๊ฐ„ modifier๋‚˜ BottomSheet ๊ฐ™์€ ๊ฒฝ์šฐ์—๋Š” ๋”ฐ๋กœ ํฌ์ŠคํŒ…ํ•  ์˜ˆ์ •์ด๋‹ค.

 

์ฝ”๋“œ ์„ค๋ช…

1. ZStack์„ ์‚ฌ์šฉํ•œ ์ด์œ ๋Š” BottomSheet์˜ hierarchy ๋•Œ๋ฌธ์ด๋‹ค. ๋‹ค์Œ ํฌ์ŠคํŒ…์—์„œ ํ•ด๋‹น ๋‚ด์šฉ์— ๋Œ€ํ•ด ์ž์„ธํ•˜๊ฒŒ ์ž‘์„ฑํ•ด์•ผ๊ฒ ๋‹ค.

2. TCA์—์„œ ์ œ๊ณตํ•˜๋Š” WithViewStore๋ฅผ ํ†ตํ•ด ์œ„์—์„œ ์ž‘์„ฑํ–ˆ๋˜ Memos Reducer๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” Store๋ฅผ ์ธ์ž๋กœ ๋„ฃ๊ณ , ์ƒํƒœ๋ฅผ observe ํ•˜๋ฉด ๋œ๋‹ค. ( ์ •ํ™•ํ•˜์ง„ ์•Š์ง€๋งŒ store๋Š” ์ปค๋งจ๋“œ์„ผํ„ฐ๋ผ๊ณ  ๋ณด๋ฉด ๋  ๊ฒƒ ๊ฐ™๋‹ค. )

3. List์˜ ์•„์ดํ…œ์„ ๋„˜๊ฒจ์ค„ ๋•Œ, memoList๋ผ๋Š” ๋ฉ”์„œ๋“œ์˜ ๋ฐ˜ํ™˜๊ฐ’์„ ๋„ฃ์–ด์ค€๋‹ค. ๊ฒ€์ƒ‰ ์ค‘์ผ ๊ฒฝ์šฐ์—๋Š” ๊ฒ€์ƒ‰๋œ ๋ฉ”๋ชจ๋งŒ ๋ณด์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด์„œ์ด๋‹ค. ๊ฒ€์ƒ‰ ์ค‘์ด ์•„๋‹ˆ๋ผ๋ฉด ๊ธฐ์กด์˜ memo ๋ฐฐ์—ด์„ ๋ณด์—ฌ์ฃผ๋ฉด ๋œ๋‹ค.

4. List์˜ ์‚ญ์ œ๋Š” onDelete๋ฅผ ํ†ตํ•ด์„œ ์ด๋ฃจ์–ด์ง„๋‹ค. ์—ฌ๊ธฐ์„œ ์ œ๊ฑฐํ•˜๊ณ ์ž ํ–ˆ๋˜ ์ธ๋ฑ์Šค๋ฅผ ๊ฐ€์ง€๊ณ  ์™€ action(.delete(id))์„ ๋ณด๋‚ด๋ฉด ์ƒํƒœ๊ฐ€ ๋ฐ”๋€Œ๊ฒŒ ๋˜๊ณ  View๊ฐ€ ์—…๋ฐ์ดํŠธ๋œ๋‹ค.

 

์ •๋ฆฌ

TCA์— ๋Œ€ํ•ด ์กฐ๊ธˆ์€ ์•Œ๊ฒŒ ๋œ ๊ฒƒ ๊ฐ™๊ณ , ๊ธฐ์กด๋ณด๋‹ค UI๋ฅผ ๊ตฌํ˜„ํ•  ๋•Œ, ์กฐ๊ธˆ์€ ๊ฐ์ด ์ƒ๊ธด ๊ฒƒ ๊ฐ™๊ธฐ๋„ ํ•˜๋‹ค.

๊ทธ๋ž˜๋„ ์—ฌ์ „ํžˆ ๊ฐˆ๊ธธ์ด ๋ฉ€๋‹ค. ์กฐ๊ธˆ ๋” ๋ณต์žกํ•œ ๋กœ์ง์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•˜๋Š” ์•ฑ์—์„œ๋Š” ํ—ค๋งฌ ์ˆ˜๋„ ์žˆ์„ ๊ฒƒ ๊ฐ™๋‹ค.

3์›” ์ „ํ›„๋กœ ์•ฑ์„ ํ•˜๋‚˜ ์ œ๋Œ€๋กœ ๋งŒ๋“ค์–ด๋ด์•ผ๊ฒ ๋‹ค.

 

๋ฐ˜์‘ํ˜•

'iOS > SwiftUI' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

[SwiftUI] ์ปค์Šคํ…€ BottomSheet์„ ๋งŒ๋“ค์–ด๋ณด์ž  (0) 2024.01.22