Contents
Chào mừng bạn đến với Fx Studio. Chúng ta lại tiếp tục hành trình trong SwiftUI dài bất tận này. Chủ đề bài viết này là Expandable List, một cách xử lý nội dung của danh sách khá là phổ biến trong các ứng dụng mobile. Và bài viết thuộc phần Working with List trong toàn bộ series SwiftUI Notes.
Nếu bạn chưa biết nhiều về thực thể List trong SwiftUI, thì có thể tham khảo các bài viết trước.
Còn nếu mọi việc đã ổn rồi, thì …
Bắt đầu thôi!
Chuẩn bị
Về mặt tool và version, các bạn tham khảo như sau:
-
- SwiftUI 2.0
- Xcode 12
Về mặt kiến thức, bạn cần biết trước các kiến thức cơ bản với SwiftUI & SwiftUI App. Tham khảo các bài viết sau, nếu bạn chưa đọc qua SwiftUI:
(Mặc định, mình xem như bạn đã biết về cách tạo project với SwiftUI & SwiftUI App rồi.)
Về mặt demo, bạn chỉ cần thực hiện demo trên các SwiftUI View đơn giản. Mình sẽ thực hiện các View riêng biệt với nhau, nên bạn không cần lo lắng gì nhiều về tính liên kết của các View trong một Project. Về mặt giao diện thì khá là đơn giản à.
(Hoặc bạn có thể checkout project demo tại đây.)
Expandable List
SwiftUI List là phiên bản của UITableView trong UIKit. Tuy nhiên, chúng được nâng cấp mạnh mẽ và linh hoạt hơn nhiều. Khi UITableView bó buộc chúng ta trong việc đồng bộ giữa dữ liệu và các cell thông qua các delegate của nó. Thì List sẽ hiển thị theo trạng thái dữ liệu của chúng ta.
Việc này làm giảm đi rất nhiều mặt custom hay logic dư thừa. Chúng sẽ chỉ cần quản lý kĩ phần dữ liệu và chỉ ra cho giao diện cách hiển thị mà thôi.
Hình trên là một ví dụ về Expandable List, nó có đặc điểm gì:
- Giống như một danh sách bình thường với các row/cell
- Khi kích vào row/cell thì nếu có thêm dữ liệu. Sẽ tiếp tục hiển thị một danh sách nữa
- Số cấp độ mở rộng sẽ không bị gới hạn
Để làm được điều này với UIKit, thì bạn cũng phải thật siêu nhân với các kĩ năng thất truyền như:
- Table trong Table
- Table trong Cell
- Co giãn cell & Ẩn hiện cell
- Mapping dữ liệu, làm phẳng array
- …
Nhưng với SwiftUI thì …
EZ Game!
Và theo kinh nghiệm cả chục năm trong nghề, mình nhận ra Expandable List cũng đáng để các bạn mới học hỏi.
Thao trường đồ mồ hôi thì chiến trường bớt đổ máu.
Setup Data Model
Cũng như các công việc khác, bạn cần tổ chức cấu trúc dữ liệu cho thật tốt để đảm bảo tính logic chương trình. Một trong những điều mà hầu như các bạn dev mới vào nghề đều bỏ qua. Với Expandable List thì kiểu dữ liệu có khác với List bình thường. Nên ta cần phải chuẩn bị cho phù hợp.
Create
Ví dụ tham khảo với chương trình hiển thị thực đơn và kiểu dữ liệu đề xuất như sau:
struct MenuItem: Identifiable { var id = UUID() var name: String var image: String var subMenuItems: [MenuItem]? }
Trong đó, toàn những hình bóng quen thuộc với List:
- Identifiable Protocol dùng để định danh cách item trong một array là duy nhất
- Đi kèm với Protocol đó là thuộc tính
id
, ta cho nó nhận giá trị từUUID()
để đảm bảo chúng nó là duy nhất - Các thuộc tính
image
&name
chứa thông tin của một món ăn trong thực đơn subMenuItems
là một Array kiểu[MenuItem]?
. Có thể có hoặc không cũng không sao.
Mấu chốt vấn đề là bạn có thể lợi dụng thuộc tính subMenuItems
để tạo nên nhiều phân cấp dữ liệu. Expandable List không chỉ đơn giản ở 2 cấp mà thôi.
Dummy Data
Để có dữ liệu làm ví dụ sinh động hơn, chúng ta lại cần chuẩn bị thêm phần dummy data
cho đẹp.
Sau này, dữ liệu của bạn sẽ lấy từ một nguồn khác, có thể là API hay Database …
Bạn thêm đoạn code sau vào chương trình.
extension MenuItem { static func dummyData() -> [MenuItem] { // list 1 let list1: [MenuItem] = [MenuItem(name: "Bánh mì", image: "img_1_01"), MenuItem(name: "Bánh bao", image: "img_1_02"), MenuItem(name: "Bánh chưng", image: "img_1_03"), MenuItem(name: "Bánh quy", image: "img_1_04"), MenuItem(name: "Bánh ít lá gai", image: "img_1_05"), MenuItem(name: "Bánh bột lọc", image: "img_1_06"), MenuItem(name: "Bánh bèo", image: "img_1_07"), MenuItem(name: "Bánh đúc", image: "img_1_08"), MenuItem(name: "Bánh chuối chiên", image: "img_1_09"), MenuItem(name: "Bánh pizza", image: "img_1_10")] let list2: [MenuItem] = [MenuItem(name: "Cơm gà", image: "img_2_01"), MenuItem(name: "Cơm tấm", image: "img_2_02"), MenuItem(name: "Cơm chiên dương châu", image: "img_2_03"), MenuItem(name: "Phở", image: "img_2_04"), MenuItem(name: "Bún bò", image: "img_2_05"), MenuItem(name: "Bánh canh", image: "img_2_06"), MenuItem(name: "Miến xào", image: "img_2_07"), MenuItem(name: "Cháo vịt", image: "img_2_08"), MenuItem(name: "Bún chả cá", image: "img_2_09"), MenuItem(name: "Mì quảng", image: "img_2_10")] let list3: [MenuItem] = [MenuItem(name: "Trà đá", image: "img_3_01"), MenuItem(name: "Nước mía", image: "img_3_02"), MenuItem(name: "Nước chanh", image: "img_3_03"), MenuItem(name: "Coca", image: "img_3_04"), MenuItem(name: "Bia", image: "img_3_05"), MenuItem(name: "Nước ép hoa quả", image: "img_3_06"), MenuItem(name: "Sinh tố", image: "img_3_07"), MenuItem(name: "Trà sữa", image: "img_3_08"), MenuItem(name: "Chè", image: "img_3_09"), MenuItem(name: "Nước lọc", image: "img_3_10")] let items: [MenuItem] = [MenuItem(name: "Bánh", image: "img_1_01", subMenuItems: list1), MenuItem(name: "Đồ ăn", image: "img_2_01", subMenuItems: list2), MenuItem(name: "Nước uống", image: "img_3_01", subMenuItems: list3)] return items } }
Trong đó:
- Ta sẽ có 3 item lớn nhất cho cấp đầu tiên
- Mỗi item lại có thêm 10 item con cho cấp thứ 2 của nó
Bạn đã hình dùng giao diện của mình sẽ trông như thế nào rồi đó. Khá vui phải không nào.
Display Expandable List
Phần dữ liệu đã xong, ta sang phần giao diện chính. Ban đầu, mình cứ tưởng làm cái này khó lắm, nhưng là hoá ra lại rất đơn giản. Tất cả chỉ có tập trung vào tham số children
của List hay ForEach mà thôi.
Bạn hãy tạo một file SwiftUI View mới và tiến hành code như với List bình thường ở bài trước. Tuy nhiên, sẽ phải sử dụng thêm tham số children
. Code ví dụ như sau:
struct ExpandableListView: View { var items = MenuItem.dummyData() var body: some View { List(items, children: \.subMenuItems) { item in HStack { Image(item.image) .resizable() .scaledToFit() .frame(width: 50, height: 50) Text(item.name) .font(.system(.title3, design: .rounded)) .bold() } } } }
Trong đó:
- List sẽ duyệt qua
items
là array dữ liệu chính children
sẽ dựa trênkey path
mà chúng ta cung cấp tới.subMenuItems
của item. Nó sẽ kiểm tra mỗi phần tử trong array có thêm một danh sách con hay không.- Tại mỗi bước lặp, ta sẽ thiết kế riêng giao diện cho từng Row.
Giao diện của mỗi Row sẽ áp dụng cho toàn bộ các cấp trong danh sách. Bạn hãy bấm Preview để test kết quả nhoé.
- Không Expand
- Có Expand
Bạn chỉ cần kích vào cái nút mũi tên đó là nó tự hiển thị ra thôi. Phần mũi tên này sẽ tự động cho bạn. Còn custom nó thì mình chưa tìm hiểu. Ahihi!
Grouped List Style
Thêm một ít màu mè cho đẹp nữa nhoé. Chúng ta sẽ grouped lại danh sách cho gọn. Bạn sẽ dùng tới modifier .listStyle
với tham số InsetGroupedListStyle
. Code ví dụ như sau:
List(items, children: \.subMenuItems) { item in //... } .listStyle(InsetGroupedListStyle())
Xem kết quả nhoé!
More Expand
Sẽ như thế nào khi chúng ta thêm một cấp nữa cho các sub item
. Thành là 3 cấp. Và vẫn như bước đầu tiên, ta lại phải chuẩn bị dữ liệu tiếp.
Lần này chỉ thêm về mặt dữ liệu, chứ không thay đổi về mặt cấu trúc dữ liệu.
Ví dụ code như sau:
let sublist2: [MenuItem] = [MenuItem(name: "Cơm gà mã lai", image: "img_2_01"), MenuItem(name: "Cơm gà luộc", image: "img_2_01"), MenuItem(name: "Cơm gà xé", image: "img_2_01")] let sublist3: [MenuItem] = [MenuItem(name: "Phở bò", image: "img_2_04"), MenuItem(name: "Phở gà", image: "img_2_04"), MenuItem(name: "Phở tái", image: "img_2_04")] let list2: [MenuItem] = [MenuItem(name: "Cơm gà", image: "img_2_01", subMenuItems: sublist2), MenuItem(name: "Cơm tấm", image: "img_2_02"), MenuItem(name: "Cơm chiên dương châu", image: "img_2_03"), MenuItem(name: "Phở", image: "img_2_04", subMenuItems: sublist3), MenuItem(name: "Bún bò", image: "img_2_05"), MenuItem(name: "Bánh canh", image: "img_2_06"), MenuItem(name: "Miến xào", image: "img_2_07"), MenuItem(name: "Cháo vịt", image: "img_2_08"), MenuItem(name: "Bún chả cá", image: "img_2_09"), MenuItem(name: "Mì quảng", image: "img_2_10")]
Ta sẽ thêm 2 danh sách item con vào 2 phần tử trong danh sách thứ 2. Bạn hãy chờ một chút hoặc clean lại project và bấm Resume nha, khi đó dữ liệu sẽ cập nhật toàn bộ cho project SwiftUI.
Xem kết quả nhoé!
Bạn sẽ thấy là từ Đồ ăn > Phở > các món phở khác
. Do đó, với tham số children
thì List sẽ duyệt tất cả các cấp trong danh sách của chúng ta. Và sẽ hiển thị đầy đủ.
Đúng là EZ Game không nào!
Outline Group
Bạn cũng nhận ra một điều, là giao diện của tất cả các Row nó rất đơn điệu và một màu phải không. Chúng ta cần phải custom để có sự tách biệt ra một ít. Lúc này, ta sẽ sử dụng thêm OutlineGroup
Mục đích, tách biệt phần children
ra khỏi các phần tử. Bên cạch đó giúp ta có thể custom phần giao diện của các phần tử ở cấp đầu tiên được đẹp hơn.
Bạn xem code ví dụ nha:
List { OutlineGroup(items, children: \.subMenuItems) { item in HStack { Image(item.image) .resizable() .scaledToFit() .frame(width: 50, height: 50) Text(item.name) .font(.system(.title3, design: .rounded)) .bold() } } } .listStyle(InsetGroupedListStyle())
Mặc dù hiện thị vẫn giống với các ở trên. Tuy nhiên, bạn sẽ thấy OutlineGroup đã được tách biệt ra rồi. Do đó, chúng ta có thể thêm bớt và custom những thứ khác vào List. Mọi thứ không còn là dính với nhau 1 chùm như ở trên.
Custom Section
Áp dụng tiếp OutlineGroup, chúng ta sẽ tiến hành custom các Header của các Section. Lúc này:
- Mỗi Section là 1 item của cấp đầu tiên
- Header sẽ chứa thông tin của item cấp đầu tiên đó
- Các Row ở trong sẽ tiếp tục hiển thị và phân cấp, dựa vào
children
của nó
Code ví dụ như sau:
List { ForEach(items) { menuItem in Section(header: ZStack { Image(menuItem.image) .resizable() .frame(width: .infinity, height: 100) .scaledToFill() Text(menuItem.name) .font(.title3) .fontWeight(.heavy) .padding() .foregroundColor(Color.white) .background(Color.black.opacity(0.5)) } .padding(.vertical) ) { OutlineGroup(menuItem.subMenuItems ?? [MenuItem](), children: \.subMenuItems) { item in HStack { Image(item.image) .resizable() .scaledToFit() .frame(width: 50, height: 50) Text(item.name) .font(.system(.title3, design: .rounded)) .bold() } } } } } .listStyle(InsetGroupedListStyle())
Trong đó:
- ForEach sẽ lặp các phần tử có trong cấp đầu tiên.
- Trong mỗi bước lặp của ForEach ta tiến hành 2 công việc:
- Custom lại Header của Section được thêm vào với thông tin chính là
item
mỗi bước lặp - Tạo thêm một OutlineGroup cho các phần tử của
.subMenuItems
, thức là các item của cấp thứ 2 - Nó sẽ duyệt tiếp nếu có cấp thứ 3 …
- Custom lại Header của Section được thêm vào với thông tin chính là
Ta thử bấm Live Preview và xem kết quả nhoé!
Ta đã có một Header nhìn xịn sò và tách biệt ra các phần tử tiếp theo rồi. Mọi thử trông ổn đó. Ahihi!
Disclosure Group
Các danh sách trên với List & OutlineGroup thì chúng đều liên quan tới danh sách hay danh sách con để có thể ẩn/hiện hoặc mở rộng ra các row tiếp theo trong giao diện.
Nhưng khi các phần tử của bạn lại không có các danh sách con. Hoặc bạn chỉ cần mở rộng để hiển thị thêm thông tin cho Row thì sẽ như thế nào?
Để giải quyết vấn đề này, SwiftUI lại cho chúng ta một đối tượng nữa, đó là Disclosure Group. Nó sẽ hoạt động độc lập theo từng phần tử và sẽ quyết định mở rộng phần thông tin nào thêm cho bạn.
Để dễ hình dùng, chúng ta sẽ làm một ví dụ với màn hình câu hỏi trong ứng dụng. Hay còn gọi là FAQ.
Setup data model
Cũng như trên, trước tiên ta cần chuẩn bị về mặt cấu trúc và dữ liệu cho ổn. Bạn xem ví dụ sau để tham khảo.
struct FAQItem: Identifiable { var id = UUID() var question: String var answer: String var showContent = true }
Trong đó:
- Kế thừa lại Identifiable Protocol như bình thường &
id
để định danh phần tử question
&answer
chứa thông tin của câu hỏishowContent
để chứa trạng thái ẩn hiện của nội dung
Mục đích của chúng ta sẽ là:
- Hiển thị phần câu hỏi ra trước.
- Khi người dùng kích vào câu hỏi, thì sẽ hiển thị câu trả lời ở dưới sẽ hiện ra.
- Danh sách của chúng ta sẽ tự động mở rộng ra và thu lại khi người dùng kích vào lại để ẩn câu trả lời.
Tiếp theo, bạn chuẩn bị dummy data
nha. Các làm tương tự ở trên.
Display FAQ
Chúng ta sẽ dùng đối tượng DisclosureGroup để thao tác hiển thị cho màn hình FAQ này. Ví dụ code sẽ như sau:
struct FAQView: View { var items = FAQItem.dummyData() var body: some View { List { ForEach(0..<items.count) { index in DisclosureGroup( //isExpanded: $items[index].showContent, content: { Text(items[index].answer) .font(.body) .fontWeight(.light) }, label: { Text("\(index + 1). \(items[index].question)") .font(.body) .fontWeight(.bold) }) } } } }
Trong đó:
- Dùng kiểu cấu trúc List và ForEach lồng nhau để có thể custom được nhiều hơn.
- Dùng kiểu lặp
index
với mục đích đánh số chỉ mục cho các item trong danh sách - Tại mỗi bước lặp ta sử dụng đối tượng DisclosureGroup. Với:
content
là phần sẽ được ẩn đi hay hiện ra lúc người dùng kích vàolabel
là phần luôn luôn hiện ra
Để dễ hình dung hơn thì bạn hay bấm Live Preview và test nhoé. Kết quả sẽ như sau:
Bạn cứ thoải mái mà kích ẩn/hiện từng item nhoé.
Show / Hide content
Để tối ưu trải nghiệm người dùng hơn, sẽ tiếp tục nâng cấp danh sách đó như sau:
- Lúc mới bắt đầu sẽ hiển thị tất cả
content
- Việc ẩn hiện
content
sẽ tuỳ thuộc vào mỗi đối tượng tự quản lý.
Lần này, để làm được như vậy. Bạn cần phải tuỳ chỉnh lại dữ liệu cho SwiftUI View này. Ta sẽ biến items
trở thành một The single source of truth. Ví dụ như sau:
@State var items = FAQItem.dummyData()
Struct FAQItem đã có sẵn thuộc tính showContent
. Chúng ta đã biến items
thành nguồn dữ liệu duy nhất cho View, thì các phần tử trong danh sách items
đó cũng trở thành nguồn dữ liệu. Mọi thao tác hay ràng buộc với View để có được sự ảnh hưởng tới items
và ngược lại.
Tiếp theo, ta dùng tới tham số isExpanded
trong DisclosureGroup để handle sự ẩn hiện của từng item. Vì danh sách items
đã là nguồn dữ liệu rồi, nên ta có thể Binding từ View tới 1 phần dữ liệu của các struct
. Cụ thể như sau:
List { ForEach(0..<items.count) { index in DisclosureGroup( isExpanded: $items[index].showContent, content: { Text(items[index].answer) .font(.body) .fontWeight(.light) }, label: { Text("\(index + 1). \(items[index].question)") .font(.body) .fontWeight(.bold) }) } } }
Trong đó, bạn chỉ cần chú ý tới isExpanded: $items[index].showContent
là đủ. Với giá trị ban đầu cung cấp cho showContent
của mỗi item là true
, nên danh sách của bạn lúc bắt đầu sẽ hiển thị tất cả các content
của toàn bộ câu câu hỏi ra.
Quan trọng, bạn có thể chỉ định hoặc quản lý ẩn/hiện cho từng item. Quá EZ! Xem kết quả nhoé!
Tạm kết
- Cấu trúc dữ liệu phù hợp cho một danh sách có khả năng mở rộng
- Hiển thị Expandable List với nhiều cấp
- Custom Expandable List với OutlineGroup
- Ẩn hiện một phần nội dụng với DisclosureGroup.
- Kết hợp giữa List & DisclosureGroup
Okay! Tới đây, mình xin kết thúc bài viết về Expandable List trong SwiftUI. Và nếu có gì thắc mắc hay góp ý cho mình thì bạn có thể để lại bình luận hoặc gởi email theo trang Contact.
Cảm ơn bạn đã đọc bài viết này!
Related Posts:
Written by chuotfx
Hãy ngồi xuống, uống miếng bánh và ăn miếng trà. Chúng ta cùng nhau đàm đạo về đời, về code nhóe!
Leave a Reply Cancel reply
Fan page
Tags
Recent Posts
- Charles Proxy – Phần 1 : Giới thiệu, cài đặt và cấu hình
- Complete Concurrency với Swift 6
- 300 Bài code thiếu nhi bằng Python – Ebook
- Builder Pattern trong 10 phút
- Observer Pattern trong 10 phút
- Memento Pattern trong 10 phút
- Strategy Pattern trong 10 phút
- Automatic Reference Counting (ARC) trong 10 phút
- Autoresizing Masks trong 10 phút
- Regular Expression (Regex) trong Swift
You may also like:
Archives
- September 2024 (1)
- July 2024 (1)
- June 2024 (1)
- May 2024 (4)
- April 2024 (2)
- March 2024 (5)
- January 2024 (4)
- February 2023 (1)
- January 2023 (2)
- November 2022 (2)
- October 2022 (1)
- September 2022 (5)
- August 2022 (6)
- July 2022 (7)
- June 2022 (8)
- May 2022 (5)
- April 2022 (1)
- March 2022 (3)
- February 2022 (5)
- January 2022 (4)
- December 2021 (6)
- November 2021 (8)
- October 2021 (8)
- September 2021 (8)
- August 2021 (8)
- July 2021 (9)
- June 2021 (8)
- May 2021 (7)
- April 2021 (11)
- March 2021 (12)
- February 2021 (3)
- January 2021 (3)
- December 2020 (3)
- November 2020 (9)
- October 2020 (7)
- September 2020 (17)
- August 2020 (1)
- July 2020 (3)
- June 2020 (1)
- May 2020 (2)
- April 2020 (3)
- March 2020 (20)
- February 2020 (5)
- January 2020 (2)
- December 2019 (12)
- November 2019 (12)
- October 2019 (19)
- September 2019 (17)
- August 2019 (10)