# List

SwiftUI에서 리스트는 UIKit에서의 테이블뷰와 동일한 역할을 한다. 리스트로 표시할 데이터를 List에 임베딩하기만 하면 끝이다.

한 셀에 여러 데이터를 넣어야 하는 경우 스택에 임베딩하면 된다.

List {
    VStack {
        Text("Hello, World!")
        Text("Hello, World!")
    }
    Text("Hello, World!")
    Image(systemName: "star")
}

리스트에 임베딩할 뷰의 타입이 동일할 필요는 없다. 위코드처럼 텍스트뷰와 이미지뷰를 섞어도 된다.

리스트를 데이터 기반으로 동적 표현을 할때 데이터를 전달하게 되는데, 해당 데이터 모델이 Identifiable프로토콜을 채택하거나 id 파라미터에 키패스 형태로 속성을 전달해도 된다.

// id값으로 사용할 속성을 키패스로 전달
List(items, id: \.name) { item in
    Text(item.name)
}

# Identifiable

List로 표현할 데이터는 Identifiable프로토콜을 필수적으로 채택한 모델이어야 하고, List에서 사용하는 API들이 일반적으로 해셔블 프로토콜을 요구하므로 Hashable프로토콜까지 채택하는 것이 일반적이다.

Identifiable프로토콜은 id속성을 구현해야 하고, 해당 속성은 해셔블 프로토콜을 채택한 타입이어야 한다. 해셔블 프로토콜을 채택한 데이터는 간단한 숫자 데이터로 표현 가능하게 된다. (일반적으로 제공되는 hashValue와 같은 속성들)

# Section

섹션으로 구분된 리스트들은 Section으로 표현 가능하다.

List {
    Section("SECTION") {
        Text("1")
        Text("2")
    }

    Section {
        Text("3")
        Text("4")
        Text("5")
    } header: {
        Text("HEADER")
    } footer: {
        Text("FOOTER")
    }
}

데이터 구조에 따라 섹션으로 구분된 리스트를 구현하려면 ForEach를 적절히 섞어준다.

struct CategorizedProduct: Identifiable, Hashable {
    let id = UUID()
    let header: String
    let footer: String?
    let list: [AppleProduct]
}
List {
    ForEach(items) { section in
        Section {
            ForEach(section.list) { item in
                Text(item.name)
            }
        } header: {
            Text(section.header)
        } footer: {
            Text(section.footer ?? "Footer")
        }
    }
}

# Customizing Lists

.listStyle 모디파이어로 리스트의 스타일을 조정할 수 있다. 기본 리스트 스타일은 insetGrouped이다.

셀에 여백을 추가하려면 리스트에 임베딩된 요소에 listRowInsets을 추가한다. UIEdgeInsets을 전달하면 된다. padding모디파이어도 사용 가능하다.

List {
    Section() {
        Text("List Row Insets")
            .listRowInsets(.init(top: 10, leading: 10, bottom: 10, trailing: 10))
    }
}

인셋이 모든 섹션 내의 셀에 적용될 예정이라면 각 셀 요소가 아닌 Section에 직접 모디파이어를 심어도 된다. Section에 전체 셀 인셋을 부여하고 내부에 다시 인셋을 추가하면, 내부가 우선순위가 더 높다. List에 심는 인셋 모디파이어는 적용되지 않는다.

리스트에 백그라운드도 추가할 수 있는데, View를 모디파이어의 파라미터로 받기 때문에 직접 정의된 뷰를 전달해도 되지만 일반적으로 Color를 전달한다.

List {
    Section() {
        Text("List Row Background")
            .listRowBackground(Color.yellow)
        Text("List Row Separator")
                    .listRowSeparator(.hidden)
    }
}

셀 구분선은 .listRowSeparator 모디파이어로 조정 가능하다. visibility값을 hidden이나 visible로 조정하면 된다. listRowSeparator모디파이어는 visiblity외에도 edges파라미터를 갖는데, 이는 구분선을 숨기거나 표시할 위치를 지정하는데에 사용된다. edges top이나 bottom, all을 지정할 수 있다.

List {
    Section() {
        Text("List Row Separator")
            .listRowSeparator(.hidden)
    }
}

.listRowSeparatorTint 모디파이어를 사용하면 구분선 색을 변경할 수 있다. 해당 모디파이어도 동일하게 edges를 지정할 수 있다.

Sectionheader 클로저를 사용하면 헤더 뷰를 커스텀 할 수도 있다.

Section() {
    Text("Custom Header")
} header: {
    CustomHeaderView(title: "title", imageName: "star") // 커스텀 뷰
}

# 버튼 셀렉션 구현

@State private var selected: AppleProduct?

var body: some View {
    VStack {
        Text("Selected: \(selected?.name ?? "NIL")")
            .font(.largeTitle)

        List(items) { item in
            Button {
                selected = item
            } label: {
                Text(item.name)
            }
        }
    }
    .
}

편집모드가 아닌 일반모드에서 데이터를 바인딩할때는 List파라미터에 전달되는 값을 직접 바인딩해주면 된다.

VStack {
    Text("\(selected.count) item(s) selected")
        .font(.title)

    List(items, id: \.self, selection: $selected) { item in
        Text(item.name)
    }
}
.toolbar {
    #if os(iOS)
    EditButton()
    #endif
}

selection 파라미터에 전달하는 selected 바인딩 변수 타입에 따라 편집모드에서 단일 선택 혹은 다중 선택 여부가 달라진다. Set<_?>? 타입인 경우 다중 선택으로, 단순 옵셔널 타입인 경우 단일 선택으로 동작한다.

# 셀 삭제 및 이동

셀을 스와이프하여 삭제하는 기능은 ForEachonDelete모디파이어를 추가하여 구현한다. 리스트에서는 onDelete 모디파이어를 제공하지 않기 때문에 ForEach를 리스트에 임베딩하여 구현해야 한다.

List {
    ForEach(items) { item in
        Text(item.name)
    }
    .onDelete(perform: { indexSet in
        items.remove(atOffsets: indexSet)
    })
}

셀 이동은 수정 모드에서만 동작한다. 삭제와 동일하게 onMove 모디파이어를 ForEach에 추가하면 된다.

List {
    ForEach(items) { item in
        Text(item.name)
    }
    .onMove(perform: { indices, newOffset in
        items.move(fromOffsets: indices, toOffset: newOffset)
    })
}

# ForEach

ForEach는 반복적인 데이터를 나타내는 뷰이다. List와 역할은 비슷하지만, 리스트는 테이블뷰 형태로 데이터를 나열하고 ForEach는 임베드하는 뷰에 따라 달라진다.

ForEach는 주로 SectionedList, 셀 삭제 및 이동, 커스텀 리스트 UI를 구현할때 사용된다.

아래는 ForEach로 그리드를 구현하는 예시 코드이다. 스택으로 ForEach를 감싸 레이아웃을 자동으로 잡아주고 있다.

VStack {
    ForEach(0 ..< 3) { row in
        HStack {
            ForEach(0 ..< 2) { col in
                ProductGridItem(product: items[row * 3 + col])
            }
        }
    }
}
.padding()

# LazyGrid

스택 기반으로 그리드 UI를 만들려면 그리드 각 요소에서 컨텐츠 사이즈를 미리 계산해두어 프레임을 디테일하게 조정해야 한다. 만약 이러한 계산이 없는 경우 컨텐츠가 생략되는 형태로 구현되어버린다.

LazyGrid를 사용하는 경우 컨텐츠 사이즈를 자동으로 계산해주어 동적으로 계산된 사이즈에 따라 컨텐츠를 모두 표시해준다. LazyGrid 생성사 파라미터에는 columnscontent가 필수이다. columns[GridItem]타입이다.

그리드는 스크롤뷰를 지원하지 않기 때문에 스크롤 형태를 구현하려면 스크롤뷰에 임베딩한다.

LazyVGrid(columns: [GridItem()]) {
    ForEach(items) { item in
        ProductGridItem(product: item)
    }
}
.padding()

columns에 전달하는 그리드 아이템 갯수에 따라 컬럼 수나 열의 수를 결정하게 된다. 해당 파라미터 코드가 길어지는 것이 일반적이기 때문에 구조체 속성으로 따로 빼두어 작성하는 것이 좋다.

struct LazyGrid: View {
    var items = AppleProduct.sampleList

    private let columns = [
        GridItem(),
        GridItem(),
        GridItem()
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(items) { item in
                    ProductGridItem(product: item)
                }
            }
            .padding()
        }
    }
}

그리드 아이템은 세가지 파라미터를 갖는다. size의 경우 .fixed, .flexible, .adaptive 세 가지가 있다. fixed는 고정된 사이즈로 그리드를 구현할때 사용하고 .flexible(minimum:,maximum:)maximum에 전달되는 값을 기준으로 최대 사이즈를 차지하도록 한다.

.adaptive는 한 열이나 행에서 가장 많은 요소들을 배치하도록 한다. minimum값을 기준으로 상위 뷰를 넘어서는 경우 다음 열이나 행으로 배치하도록 하는 것이다.

그리드 아이템의 사이즈 파라미터는 타입을 섞어서 사용 가능하다.

참고로 LazyGrid를 사용하여 핀터레스트 형태의 뷰를 구현하는 코드를 첨부한다.

ScrollView {
    HStack(alignment: .top) {
        LazyVGrid(columns: columns) {
            ForEach(evenItems) { item in
                ProductGridItem(product: item)
                    .clipped()
                    .clipShape(RoundedRectangle(cornerRadius: 15))
                }
        }

        LazyVGrid(columns: columns) {
            ForEach(oddItems) { item in
                ProductGridItem(product: item)
                    .clipped()
                    .clipShape(RoundedRectangle(cornerRadius: 15))
                }
        }
    }
    .padding()
}

컬럼당 LazyVGrid를 구현하고 각 컬럼에 맞는 데이터를 추가해주는 형태이다. VGridadaptive아이템을 추가하여 내부 컨텐츠 사이즈를 동적으로 구성하도록 하였다.

# 당겨서 새로고침

리스트 모디파이어에는 refreshable이 있다. 해당 클로저를 구현하면 당겨서 새로고침 기능을 구현할 수 있다. 클로저 내의 동작이 모두 마무리 될때까지 인디케이터가 살아있게 된다.

List(items) { item in
    Text(item.name)
}
.animation(.easeInOut, value: items)
.refreshable {
    await refresh() // 직접 구현한 async 메서드
}

# SwipeAction

swipeAction 모디파이어를 사용하면 리스트 셀 삭제 및 이동 외에도 스와이프에 대한 동작을 직접 정의할 수 있다.

List {
    Section("Favorites") {
        ForEach(favorites) { item in
            Text(item.name)
                .swipeActions {
                    Button(role: .destructive) {
                        withAnimation {
                            if let index = favorites.firstIndex(of: item) {
                                favorites.remove(at: index)
                            }
                        }
                    } label: {
                        Image(systemName: "trash")
                    }
                }
        }
    }

    Section("All Products") {
        ForEach(allProducts) { item in
            Text(item.name)
                .swipeActions {
                    Button {
                        withAnimation {
                            favorites.append(item)
                        }
                    } label: {
                        Image(systemName: "hand.thumbsup")
                    }
                    .tint(.blue)
                }
        }
    }
}

스와이프 액션은 여러 액션들을 동시에 등록 가능하다.

Text(item.name)
    .swipeActions {
        Button {
            withAnimation {
                favorites.append(item)
            }
        } label: {
            Image(systemName: "hand.thumbsup")
        }
        .tint(.blue)

        Button(role: .destructive) {
            withAnimation {
                if let index = favorites.firstIndex(of: item) {
                    favorites.remove(at: index)
                }
            }
        } label: {
            Image(systemName: "trash")
        }

        Button {

        } label: {
            Text("Menu")
        }
    }

swipeAction모디파이어 파라미터에 edge를 전달할 수 있는데, 스와이프 액션 버튼의 위치를 결정할 수 있다.

스와이프 액션 삭제 동작을 구현하는 경우, 아이템에 대한 인덱스 정보를 가져야 하기 때문에 액션 모디파이어를 추가할 위치를 잘 고려해야 한다. 반드시 리스트 최상위 위치에 추가할 필요가 없고 리스트 요소 뷰에 직접 액션을 추가해도 동일하게 동작하기 때문에 위의 예시 코드처럼 구현하면 된다.

스와이프 액션에서 스와이프를 절반 이상 하는 경우 풀 스와이프라고 한다. 가장 첫번째에 추가되는 버튼의 액션이 수행되므로, 이를 막고싶은 경우 스와이프 액션 모디파이어 파라미터에 allowsFullSwipe: false를 전달한다.

# 검색기능 구현

리스트에 searchable 모디파이어를 추가하면 검색기능을 구현할 수 있다. text파라미터에 전달하는 바인딩 속성에 검색창에서 입력한 텍스트값을 바인딩해주는 것으로 서처블 모디파이어의 역할은 끝난다.

실제 검색을 통해 리스트에 대한 검색기능 구현은 onChange에 검색하는 텍스트를 바인딩하여 구현해야 한다.

List(items) { item in
    Text(item.name)
}
.searchable(text: $keyword, placement: .automatic, prompt: "검색어를 입력하세요") {
    Label("MacBook", systemImage: "laptopcomputer")
        .searchCompletion("MacBook")
}
.onChange(of: keyword) { oldValue, newValue in
    if newValue.count > 0 {
        items = AppleProduct.sampleList.filter {
            $0.name.contains(newValue)
        }
    } else {
        items = AppleProduct.sampleList
    }
}

또한 searchable 모디파이어에는 suggestions 클로저를 전달할 수 있다. 해당 클로저는 초기 검색제안을 담당한다. 검색제안 요소에 searchCompletion모디파이어를 추가한 뒤 텍스트값을 전달하면 검색제안 요소 탭 시 검색창에 텍스트가 자동으로 바인딩된다.

위 코드에서 searchCompletion에 전달된 MacBook텍스트값이 탭 이후 자동으로 서처블 모디파이어의 keyword에 바인딩된다는 것이다.

onSubmit을 활용하면 서치바 텍스트 키보드에서 제출버튼을 누르는 것으로 검색창 액션을 트리거 할 수도 있다.