# 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
를 지정할 수 있다.
Section
의 header
클로저를 사용하면 헤더 뷰를 커스텀 할 수도 있다.
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<_?>?
타입인 경우 다중 선택으로, 단순 옵셔널 타입인 경우 단일 선택으로 동작한다.
# 셀 삭제 및 이동
셀을 스와이프하여 삭제하는 기능은 ForEach
에 onDelete
모디파이어를 추가하여 구현한다. 리스트에서는 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
생성사 파라미터에는 columns
와 content
가 필수이다. 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
를 구현하고 각 컬럼에 맞는 데이터를 추가해주는 형태이다. VGrid
에 adaptive
아이템을 추가하여 내부 컨텐츠 사이즈를 동적으로 구성하도록 하였다.
# 당겨서 새로고침
리스트 모디파이어에는 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
을 활용하면 서치바 텍스트 키보드에서 제출버튼을 누르는 것으로 검색창 액션을 트리거 할 수도 있다.