[.There 프로젝트 일지] - UI 구성하기

오늘은 .There이라는 공유 앨범을 만들어주는 새로운 프로젝트가 시작되어 기록으로 남겨두려고 한다.

 

이 프로젝트는 처음으로 SwiftUI로만 구현할 예정이며, 나아가 반응형 프로그래밍인 Combine도 활용해 보려고 한다.

 

먼저 SwiftUI로 UI를 구성하기 시작했는데 UIKit으로 그리는 것보다 훨씬 더 내 스타일이고 오랜만에 정말 재밌게 코드를 작성한 것 같다!

 

내가 맡은 부분은 메인페이지인 .there 페이지와 .List 페이지의 UI 구성을 맡아 진행하였다.

일단 아직 저장되는 데이터는 없기 때문에 더미 데이터를 사용해 채워갈 예정이다.

 

View 분리하며 UI 구성하기

SwiftUI를 하면서 가장 좋은 점은 재사용성과 생산성인 것 같다. 이 점을 극대화 할 수 있도록 하나의 뷰 안에서도 세분화해 컴포넌트들을 구현하므로써 가독성이 높아지고 유지보수도 용이하게 구성할 수 있었다.

 

먼저 사용하는 더미데이터를 만들어 주었다.

struct ListItem: Identifiable {
    let id = UUID()
    let status: Status
    let title: String
    let date: Date
    let subTitle: String
    let photoCount: Int
}

enum Status {
    case success, warning, error
}

func generateDummyData() -> [ListItem] {
    (1...40).map { index in
        ListItem(
            status: index % 3 == 0 ? .success : (index % 3 == 1 ? .warning : .error),
            title: "모두 함께 가자 \\(index)",
            date: Date(),
            subTitle: "앨범 이름 / 부제 \\(index)",
            photoCount: 10
        )
    }
}

들어가야하는 데이터의 모델을 만들어주고 LazyGrid , LazyStack에 들어갈 더미 데이터도 같이 작업해 주었다.

 

그 다음 데이터가 들어가는 메인 페이지인 .there를 살펴보면

import SwiftUI

struct ThereView: View {
    
    @State private var date: Date = Date()
    @State private var title: String = "모두 함께 가자"
    @State private var subTitle: String = "앨범 이름 / 부제"
    @State private var photoCount: Int = 0
    @State private var listItems: [ListItem] = generateDummyData()
    @State private var searchText: String = ""
    @State private var isSearchBar: Bool = false
    
    var filteredItems: [ListItem] {
        if searchText.isEmpty {
            return listItems
        } else {
            return listItems.filter { $0.title.contains(searchText) || $0.subTitle.contains(searchText)}
        }
    }
    
    var body: some View {
        NavigationView {
            VStack {
                if isSearchBar {
                    SearchBar(text: $searchText)
                        .transition(.move(edge: .top).combined(with: .opacity))
                        .animation(.easeInOut(duration: 0.3), value: isSearchBar)
                        .padding(.top, 8)
                }
                ScrollView {
                    VStack {
                        TopGridView(items: listItems)
                        
                        Text("Recent Update")
                            .font(.title3)
                            .fontWeight(.bold)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding()
                        
                        ItemListView(items: filteredItems)
                    }
                } //: ScrollView
                .navigationTitle(".there")
                .navigationBarItems(trailing: Button(action: {
                    withAnimation {
                        isSearchBar.toggle()
                        if !isSearchBar {
                            searchText = ""
                        }
                    }
                }, label: {
                    Image(systemName: "magnifyingglass")
                        .foregroundColor(.black)
                }))
            } //: Navi
        }
    }
}

#Preview {
    ThereView()
}

이렇게 구현해두었는데 이건 지금 3가지의 컴포넌트화 된 뷰를 사용해서 완성된 페이지이다.

 

먼저 SearchBar

if isSearchBar {
    SearchBar(text: $searchText)
        .transition(.move(edge: .top).combined(with: .opacity))
        .animation(.easeInOut(duration: 0.3), value: isSearchBar)
        .padding(.top, 8)
}

별도로 구현해둔 서치바를 그대로 가져와서 애니메이션 효과만 넣어주는 형식으로 구현하였다.

 

 

다음은 TopGridView, ItemListView

ScrollView {
    VStack {
        TopGridView(items: listItems)
        
        Text("Recent Update")
            .font(.title3)
            .fontWeight(.bold)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
        
        ItemListView(items: filteredItems)
    }
} //: ScrollView

서치바 밑에 들어올 가로로 스크롤 되는 그리드를 별도로 구현해둔 뒤 그대로 가져와서 사용하였고 가운데 하단의 LazyVStack 도 별도로 구현한뒤 가져와서 이렇게 가독성 좋고 유지보수성도 높도록 구현할 수 있었다.

 

그럼 각각 어떻게 구현하였는지 세부적으로 살펴보자

 

SearchBar

searchBar는 다음에 .List를 구현할때도 동일한 서치바를 사용할 것이기 때문에 재사용성을 높히기 위해 별도로 구현한 뒤 가져와서 사용하였다.

import SwiftUI

struct SearchBar: View {
    @Binding var text: String
    
    var body: some View {
        TextField("Search", text: $text)
            .padding(8)
            .background(Color(.systemGray6))
            .cornerRadius(8)
            .padding(.horizontal)
    }
}

#Preview {
    @State var text = ""
    return SearchBar(text: $text)
}

이렇게 텍스트 필드에 값이 들어오도록 하고 해당 내용이 서치바 역할을 할 수 있도록 구현하였다.

TopGridView

import SwiftUI

struct TopGridView: View {
    let items: [ListItem]
    
    let columns: [GridItem] = [GridItem(.flexible(maximum: 200))]
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            LazyHGrid(rows: columns, spacing: 6) {
                ForEach(items) { item in
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color.gray.opacity(0.3))
                        .frame(width: 240, height: 320)
                        .overlay(
                            VStack(alignment: .leading, spacing: 6) {
                                Spacer()
                                Text("\\(formattedDate(item.date))")
                                    .font(.caption)
                                Text("\\(item.title)")
                                    .font(.subheadline)
                                Text("\\(item.subTitle)")
                                    .font(.caption)
                                
                                Capsule()
                                    .fill(Color.white)
                                    .frame(width: 75, height: 25)
                                    .overlay(
                                        HStack {
                                            Image(systemName: "photo")
                                                .imageScale(.small)
                                            Text("\\(item.photoCount)")
                                                .font(.caption)
                                                .padding(.leading)
                                        }
                                    )
                            }
                                .padding()
                                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
                        ) //: overlay
                } //: ForEach
            } //: LazyHGrid
            .padding(.leading)
        } //: ScrollView
    } //: body
    
    func formattedDate(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy.MM.dd a HH:mm"
        return formatter.string(from: date)
    }
}

가로로 스와이프가 되는 UIKit에서 CollectionView라고 할 수 있는 그리드를 사용하였다.

 

더미 데이터를 넣어준 뒤 각각의 라운드랙탱글을 넣어주었고 그 안을 overlay를 활용해 텍스트와 이미지를 넣어주었다.

 

이렇게 구현하면 사진과 같이 별도의 그리드가 만들어 지는 것을 볼 수 있다.

ItemListView

다음은 최근 공유 앨범의 목록을 테이블 뷰처럼 보일 수 있도록 구현하였다.

import SwiftUI

struct ItemListView: View {
    let items: [ListItem]
    
    var body: some View {
        
        LazyVStack(alignment: .leading, spacing: 10) {
            ForEach(items) { item in
                HStack(alignment: .top) {
                    Circle()
                        .fill(color(for: item.status))
                        .frame(width: 10, height: 10)
                        .padding(.top, 4)
                    
                    VStack(alignment: .leading, spacing: 6) {
                        Text(item.title)
                            .font(.subheadline)
                            .fontWeight(.semibold)
                        Text(formattedDate(item.date))
                            .font(.caption)
                            .foregroundColor(.secondary)
                        Text(item.subTitle)
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                    .padding(.leading, 5)
                } // : HStack
                Divider()
            } // : ForEach
        } // : LazyVStack
        .padding(.horizontal, 30)
    }
    
    func formattedDate(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy.MM.dd a HH:mm"
        return formatter.string(from: date)
    }
    
    func color(for status: Status) -> Color {
        switch status {
        case .success:
            return .blue
        case .warning:
            return .yellow
        case .error:
            return .red
        }
    }
}

비슷한 역할을 List가 해줄 수 있지만 나는 Lazy에 개념에 대해서 한번 더 짚고 넘어가고 싶어서 LazyVStack으로 구현하게 되었다.

이렇게 각각 컴포넌트화 해서 구현한 다음 메인 view에 해당하는 view만 불러와서 그대로 사용만 하면 메인 페이지가 완성된다.

 

ItemListView 같은 경우에도 다음에 할 .List 페이지에서 동일한 LazyVStack을 그대로 사용하기 때문에 이 내용을 그대로 불러와서 사용하면 된다.

 

이처럼 재사용성이 높아지고 각각 세분화 해서 구현하기 때문에 가독성도 높아 유지보수가 용이해지는 장점이 있다.

 

이렇게 세분화해서 각각의 역할을 주는 느낌이 너무너무 재밌다. 이 세개를 합치게 되면 마지막 결과물 하나의 페이지가 뚝딱 완성된다 🙂