SwiftUI 스터디 4. List, GeometryReader, Animation, Selection, SF Symbols

2021. 6. 3. 17:54Developer.TokkiSea/Apple

반응형

 

3편에 이어서

 

이번에는 List를 해보겠습니다.

실무 수준에 쓸 수 있도록 이것저것 해보았습니다만

그래도 부족한 느낌이 드네요.

 

리스트에 사용할 데이터 구조체를 하나 만들어 줍니다.

struct ListData {
    let id: String
    let level: Int
    let nickName: String
    static func getUsers() -> [ListData] {
        return [
            ListData(id: "TokkiSea", level: 25, nickName: "토끼씨"),
            ListData(id: "jellapi", level: 70, nickName: "젤라피"),
            ListData(id: "Sukki", level: 90, nickName: "수기"),
            ListData(id: "Rabbit", level: 50, nickName: "토끼")
        ]
    }
}

아래처럼 되는 리스트를 만들어 보겠습니다.

파란색은 리스트 배경 색상입니다.

리스트를 기본으로 생성하면 저렇게 사각 모서리가 둥글게 만들어지고

간격이 알맞게 띄워집니다.

 

GeometryReader { geometry in
    List {
        ForEach(ListData.getUsers(),id: \.id) { listData in
            HStack {
                Text(listData.id)
                    .frame(width:100)
                    .background(Color.green)
                Text("\(listData.level)")
                    .frame(alignment:.center)
                    .background(Color.orange)
                Text(listData.nickName)
                    .frame(height:30)
                    .background(Color.gray)
            }
            .listRowInsets(.init())
            .frame(width: geometry.size.width, height: 20,  alignment: .leading)
            .border(Color.red, width: 1)
        }
    }
    //.listStyle(PlainListStyle())
    .environment(\.defaultMinListRowHeight, 30)
}

GeometryReader

제일 처음 나오는데 화면 사이즈를 가져올 때 사용합니다.

정확한 화면 크기나(size), 안정영역 포함 크기(safeAreaInsets)를 사용할 수 있습니다.

List

리스트는 사용형식이 아주 많습니다. 일단 기본적인 List { } 만으로 시작합니다.

리스트 데이터를 List(... 형식으로 넣어도 되는데

이렇게 내부에서 ForEach 로 데이터를 불러와 넣어도 됩니다.

ForEach 반복 회수 만큼 리스트가 만들어집니다.

id: 는 리스트를 구분하는 key 형태로 쓰이는데

id (TokkiSea, jellapi, Sukki, Rabbit)가 Key값이 됩니다.

만약 사용하는 데이터의 배열이 한 가지라면

예를 들어 ["TokkiSea","jellapi","Sukki","Rabbit"] 이것밖에 없다면

/.self 형태로도 쓸 수 있습니다.

 

지금 이 예제는 가져온 데이터가 ListData 구조체를 가져왔기에

ListData 내부의 /.id 를 Key로 사용하겠다고 지정했습니다.

 

리스트의 한 항목을 HStack { } 로 나열했고

첫 번째 항목은 .frame(width:100)를 줘서 고정크기가 되게 했습니다.

(초록색 배경)

 

두 번째 항목은 .frame(alignment:.center)으로 중앙 정렬만 했습니다.

(오렌지색 배경)

 

세 번째 항목은

.frame(height:30)로 높이만 30으로 두었습니다.

(회색 배경)

 

.environment(\.defaultMinListRowHeight, 30)

리스트의 기본 크기를 30으로 주었기 때문에

세 번째 항목은 하나의 셀 높이를 가득 채웁니다.

가변 길이와 좌측 정렬이라 보기는 좋지 않게 그려집니다.

 

한 줄의 셀로 사용하고 있는 높이는 20입니다.

.frame(width: geometry.size.width, height: 20,  alignment: .leading)

HStack의 width는 최대치, 높이가 20입니다.

HStack의 .border(Color.red, width: 1) 빨간색 사각형을 보면

세 번째 항목이 높이가 30이라 범위가 넘어가는 걸 볼 수 있습니다.

 

이미지의 빨간색 볼더의 오른쪽 끝을 자세히 보시면

딱 맞게 안 떨어지고 범위를 넘어서는 걸 볼 수 있습니다.

첫 번째, 네 번째는 잘려 진상 태고 (라운딩 모양이라 그런 것 같네요.)

두 번째, 세 번째는 넘겨져서 그려지고 있습니다.

 

셀과 항목들은 각각 높이 30, 20 등등 지정해준 크기대로

그려지고 있습니다.

.listRowInsets(.init())

요거 때문에 그런 건데 이걸 빼주면 아래처럼

상하좌우 공백이 생겨서 보다 더 자연스러운 리스트를 만들어 줍니다.

(물론 이 예제의 모양은 보기 좋지 않지만..)

그렇다면 저 동그란 라운딩도 없애고 싶다면

리스트 스타일을 없게 만들면 됩니다.

 

List { }.listStyle(PlainListStyle())

 

그러면 라운딩도 없어지고

딱 내가 지정했던 모양으로만 만들어집니다.

그리고 빨간색 볼더 사각형도 딱 맞습니다.

GeometryReader.size는 SafeArea를 무시한 크기입니다.

 

두 번째 리스트는 선택이 되는 리스트입니다.

GeometryReader { geometry in
    VStack {
        List(ListData.getUsers(),id:\.id,selection: $selectionID) { listData in
            HStack{
                Text(listData.id)
                    .listRowInsets(.init())
                    .frame(width:100)
                    .background(Color.green)
                Text("\(listData.level)")
                    .frame(alignment:.center)
                    .background(Color.orange)
                Text(listData.nickName)
                    .frame(height:30)
                    .background(Color.gray)
            }
            .listRowInsets(.init())
            .frame(width: geometry.size.width-32,
                   height: 20,
                   alignment: .leading)
            .border(Color.red, width: 1)

        }
        .listStyle(GroupedListStyle())
        .environment(\.defaultMinListRowHeight, 30)
    } // VStack
    .navigationBarTitle("TokkiSea")
    .navigationBarItems(leading:
        HStack {
            EditButton()
            Text("\(self.selectionID.description)")
        }
    )
} //GeometryReader
.background(Color.blue)

 

이번에는 리스트 사용방법이 달라집니다.

 List(ListData.getUsers(),id:\.id,selection: $selectionID) { listData in

}

List(... 에서 데이터를 가져와 리스트를 만들고

선택된 id 값들을 $selectionID에 저장합니다.

맨 아랫부분 내비게이션 바 아이템으로

EditButton() 과 Text("...")를 추가했습니다.

그래서 상단에 Edit 버튼과 텍스트"[]" 이 보입니다.

self.selectionID.description 으로 찍은 거라 선택된 아이템이 없는 배열인

"[]" 만 보이고 있는 중입니다.

이제 Edit 버튼을 눌러봅니다.

 

그리고 리스트 스타일이 .listStyle(GroupedListStyle())인 상태입니다.

뭔가 라운딩이 없으면서 적당히 각지게 그려진 상태예요.

그리고 빨간색 볼더 사각형은 좌우 16씩 해서 길이를 32가 줄였습니다.

.frame(width: geometry.size.width-32, height: 20, alignment: .leading)

(조~금 깔끔해졌네요.)

선택할 수 있는 라디오 버튼이 생기고 Edit 버튼은 Done으로 바뀝니다.

선택을 해보면 "["Sukki",... "형태로 잘 선택이 됩니다.

 

이제 마지막 리스트 형태를 해보겠습니다.

이번에는 셀을 그려주는 구조체를 따로 만들어 두겠습니다.

struct MultipleSelectionCell: View {
    var idString: String
    var level: Int
    var nickNameStaring: String
    var isSelected: Bool
    var action: () -> Void
    var body: some View {
        GeometryReader { geometry in
            Button(action: self.action) {
                HStack {
                    Text(idString)
                        .frame(width: (geometry.size.width/3) - 20,
                               height: 20,  alignment: .center)
                    Spacer()
                    Text("\(level)")
                        .frame(width: (geometry.size.width/3) - 20,
                               height: 20,  alignment: .center)
                    Spacer()
                    Text(nickNameStaring)
                        .frame(width: (geometry.size.width/3) - 20,
                               height: 20,  alignment: .center)
                    
                    if self.isSelected {
                        Image(systemName: "checkmark")
                            .frame(width: 20,
                                   height: 20,  alignment: .center)
                    } else {
                        Text("")
                            .frame(width: 20,
                                   height: 20,  alignment: .center)
                    }
                }//HStack
            }//Button
        }//GeometryReader
    }
}

var action: () -> Void 형태의 클로저를 하나 만들어 둡니다.

Button 액션 클로저 연결용으로 사용할 수 있습니다.

Image(systemName: "")는 기본으로 제공되는 아이콘을 이름으로 불러옵니다.

이름 종류는 맨 아래 설명을 추가하겠습니다.

 

이제 리스트 만들어주는 부분을 보겠습니다.

@State var selectionsData: [ListData] = []

GeometryReader { geometry in
    VStack {
        List {
            ForEach(ListData.getUsers(), id: \.id) { listData in
                Group {
                    MultipleSelectionCell(idString: listData.id, 
                        level:listData.level, 
                        nickNameStaring: listData.nickName, 
                        isSelected: self.selectionsData.contains(where: 
                        	{ $0.id == listData.id })
                    ) {
                        if self.selectionsData.contains(where: { $0.id == listData.id }) {
                            withAnimation {
                                self.selectionsData.removeAll(where: { $0.id == listData.id })
                            }
                        }
                        else {
                            withAnimation {
                                self.selectionsData.append(listData)
                            }
                        }
                    }
                }//HStack
                .listRowInsets(.init())
                .frame(width: geometry.size.width, height: 20,
                       alignment: .leading)
                .border(self.selectionsData.contains(where: { $0.id == listData.id }) ? Color.orange.opacity(1) : Color.orange.opacity(0), width: 1)
            }
        }.listStyle(PlainListStyle())
        .environment(\.defaultMinListRowHeight, 30)
        .frame( height: 120)
        Text("\(self.selectionsData.description)")
            .frame(width:geometry.size.width, height: 300, alignment: .top)
    } // VStack
    .navigationBarTitle("TokkiSea")
} //GeometryReader
.background(Color.init(CGColor.init(red: 0.7, green: 0.7, blue: 0.7, alpha: 0.7)))

처음에 만든 것처럼 List { ForEach 형태로 만듭니다.

그리고 선택된 ListData 구조체를 저장할 

@State var selectionsData: [ListData] = []

배열을 하나 만들어 둡니다.

 

MultipleSelectionCell(idString: listData.id, level:listData.level, 
nickNameStaring: listData.nickName, 
isSelected: self.selectionsData.contains(where: { $0.id == listData.id })

 

형태로 따로 만든 셀 생성 구조체를 사용합니다.

isSelected 부분을 보면 Bool을 전달하게 되어 있는데

선택된 데이터에 포함되면 true를 전달하게 되어 있습니다.

 

그리고 MultipleSelectionCell에서 전달해오는 Button 액션의 동작으로

아래처럼 selectionsData에 선택 구조체 정보를 넣거나 뺍니다.

if self.selectionsData.contains(where: { $0.id == listData.id }) {
    withAnimation {
      self.selectionsData.removeAll(where: { $0.id == listData.id })
    }
  } else {
    withAnimation {
      self.selectionsData.append(listData)
    }
}

contains으로 id 가 일치하는 값을 찾거나, 삭제하거나 합니다.

append로 ListData 전체를 추가하기도 합니다.

그리고 withAnimation { }가 붙어 있습니다.

selectionData의 값이 변하면서 생기는 변화를 모두

서서히 변하도록 애니메이션을 넣어주었습니다.

동작이 아주 부드러워지게 됩니다.

 

깔끔한 리스트가 만들어졌고

아래 선택된 내용을 표시할 Text도 하나 넣어 뒀습니다. ("[]"부분)

이제 선택을 해보면 스무스하게 오렌지색 박스와 체크마크

아래 Text 내용이 나타나는 걸 볼 수 있습니다.

 

 

이상으로 마음대로 커스텀하게 리스트를 만드는 방법을 알아봤습니다.

다음에는 셀 내부 내용 다루는 부분을 한번 해봐야겠네요.

 

 

심벌 사용하기

 

애플에서 제공하는 SF Symbols라는 앱을 다운로드 받아봅니다.

 

 

checkmark 가 보입니다.

어지간한 UI 이미지는 이 심벌만으로도 해결이 가능합니다.

스토리보드의 Image도 동일하게 사용 가능하고

크기나 색상 조절도 가능합니다.

 

 

 

 

 

5편에서 계속

 

 

 

반응형