WWDC20 swiftUI新增LazyVGrid
和LazyHGrid
兩種布局方式吕座,我們可以使用它們做網(wǎng)格布局。
雖然這些新組件解鎖了非常強大的布局,但SwiftUI還沒有提供UICollectionView
那樣的靈活性。
我指的是在同一個容器中有不同大小的多個視圖的可能性,并在沒有更多可用空間時使容器自動換行到下一行仪糖。
在本文中,讓我們探索如何構建我們自己的FlexibleView
迫肖,這里是最終結(jié)果的預覽:
介紹
從上面的預覽應該很清楚我們的目標是什么锅劝,讓我們看看我們的視圖要怎么實現(xiàn)它:
- 獲得水平方向的可用空間
- 獲取每個元素的size
- 一種將每個元素分配到正確位置的方法
獲取Size of View
這個文章將使用SwiftUI:GeometryReader一文中的擴展方法:
extension View {
func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
1.獲得水平方向的可用空間
FlexibleView
需要的第一個信息是總水平可用空間:
用Color
做個例子
var body: some View {
Color.clear
.frame(height: 1)
.readSize { size in
// the horizontal available space is size.width
}
}
因為第一個組件僅用于獲取布局信息,所以我們使用Color.clear
蟆湖。清晰有效地故爵,它是一個不可見的層,不會阻擋視圖的其余部分。
我們也可以設置一個.frame
修飾符限制Color
的高為1诬垂,確保視圖組件有足夠的高度劲室。
Color
不是視圖層次結(jié)構的一部分,我們可以用ZStack
隱藏它:
var body: some View {
ZStack {
Color.clear
.frame(height: 1)
.readSize { size in
// the horizontal available space is size.width
}
// Rest of our implementation
}
}
最后结窘,讓我們利用回調(diào)從readSize
存儲我們的可用水平空間在FlexibleView
中:
struct FlexibleView: View {
@State private var availableWidth: CGFloat = 0
var body: some View {
ZStack {
Color.clear
.frame(height: 1)
.readSize { size in
availableWidth = size.width
}
// Rest of our implementation
}
}
}
在這一點上很洋,我們有一個視圖,它填滿了所有可用的水平空間隧枫,并且只在高度上取一點喉磁。我們可以進入第二步。
2.獲取每個元素的size
在討論如何獲取每個元素大小之前官脓,讓我們先設置視圖來接受元素协怒。
為了簡單起見,也為了后面更清楚卑笨,我們將要求:
-
Collection
集合中的元素實現(xiàn)Hashable
協(xié)議 - 一個方法孕暇,給定該集合的一個元素,該方法返回一個視圖View
struct FlexibleView<Data: Collection, Content: View>: View
where Data.Element: Hashable {
let data: Data
let content: (Data.Element) -> Content
// ...
var body: some View {
// ...
}
}
讓我們忘記最終的布局赤兴,只關注每個元素的大小:
struct FlexibleView<...>: View where ... {
let data: Data
let content: (Data.Element) -> Content
@State private var elementsSize: [Data.Element: CGSize] = [:]
// ...
var body: some View {
ZStack {
// ...
ForEach(data, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
注意我們是如何在元素視圖上使用.fixedsize
修飾符的妖滔,讓它根據(jù)需要占用盡可能多的空間,而不管實際有多少空間可用搀缠。
這樣铛楣,我們就有了每個元素的大小!是時候面對最后一步了近迁。
3.一種將每個元素分配到正確位置的方法
這就是所有FlexibleView
需要將元素視圖分布到多行中:
struct FlexibleView<...>: View where ... {
// ...
func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth
for element in data {
let elementSize = elementSizes[element, default: CGSize(width: availableWidth, height: 1)]
if remainingWidth - elementSize.width >= 0 {
rows[currentRow].append(element)
} else {
// start a new row
currentRow = currentRow + 1
rows.append([element])
remainingWidth = availableWidth
}
remainingWidth = remainingWidth - elementSize.width
}
return rows
}
}
computeRows
將所有元素分布在多行中艺普,同時保持元素的順序,并確保每一行的寬度不超過之前獲得的可用寬度鉴竭。
換句話說歧譬,該函數(shù)返回一個行數(shù)組,其中每行包含該行的元素數(shù)組搏存。
然后瑰步,我們可以將這個新函數(shù)與HStacks
和Vstack
結(jié)合起來,得到最終的布局:
struct FlexibleView<...>: View where ... {
// ...
var body: some View {
ZStack {
// ...
VStack {
ForEach(computeRows(), id: \.self) { rowElements in
HStack {
ForEach(rowElements, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
}
}
// ...
}
在這一點上璧眠,
FlexibleView
將只采取這個VStack
的高度
有了這個缩焦,我們就結(jié)束了!最終的項目還處理了元素之間的間距和不同的排列:一旦理解了上面的基本原理,添加這些特性就變得很簡單了责静。
完整代碼:
//ContentView.swift
import SwiftUI
class ContentViewModel: ObservableObject {
@Published var originalItems = [
"Here’s", "to", "the", "crazy", "ones", "the", "misfits", "the", "rebels", "the", "troublemakers", "the", "round", "pegs", "in", "the", "square", "holes", "the", "ones", "who", "see", "things", "differently", "they’re", "not", "fond", "of", "rules", "You", "can", "quote", "them", "disagree", "with", "them", "glorify", "or", "vilify", "them", "but", "the", "only", "thing", "you", "can’t", "do", "is", "ignore", "them", "because", "they", "change", "things", "they", "push", "the", "human", "race", "forward", "and", "while", "some", "may", "see", "them", "as", "the", "crazy", "ones", "we", "see", "genius", "because", "the", "ones", "who", "are", "crazy", "enough", "to", "think", "that", "they", "can", "change", "the", "world", "are", "the", "ones", "who", "do"
]
@Published var spacing: CGFloat = 8
@Published var padding: CGFloat = 8
@Published var wordCount: Int = 75
@Published var alignmentIndex = 0
var words: [String] {
Array(originalItems.prefix(wordCount))
}
let alignments: [HorizontalAlignment] = [.leading, .center, .trailing]
var alignment: HorizontalAlignment {
alignments[alignmentIndex]
}
}
struct ContentView: View {
@StateObject var model = ContentViewModel()
var body: some View {
ScrollView {
FlexibleView(
data: model.words,
spacing: model.spacing,
alignment: model.alignment
) { item in
Text(verbatim: item)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
)
}
.padding(.horizontal, model.padding)
}
.overlay(Settings(model: model), alignment: .bottom)
}
}
struct Settings: View {
@ObservedObject var model: ContentViewModel
let alignmentName: [String] = ["leading", "center", "trailing"]
var body: some View {
VStack {
Stepper(value: $model.wordCount, in: 0...model.originalItems.count) {
Text("\(model.wordCount) words")
}
HStack {
Text("Padding")
Slider(value: $model.padding, in: 0...60) { Text("") }
}
HStack {
Text("Spacing")
Slider(value: $model.spacing, in: 0...40) { Text("") }
}
HStack {
Text("Alignment")
Picker("Choose alignment", selection: $model.alignmentIndex) {
ForEach(0..<model.alignments.count) {
Text(alignmentName[$0])
}
}
.pickerStyle(SegmentedPickerStyle())
}
Button {
model.originalItems.shuffle()
} label: {
Text("Shuffle")
}
}
.padding()
.background(Color(UIColor.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.primary, lineWidth: 4)
)
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
//FlexibleView.swift
struct FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let data: Data
let spacing: CGFloat
let alignment: HorizontalAlignment
let content: (Data.Element) -> Content
@State private var availableWidth: CGFloat = 0
var body: some View {
ZStack(alignment: Alignment(horizontal: alignment, vertical: .center)) {
Color.clear
.frame(height: 1)
.readSize { size in
availableWidth = size.width
}
_FlexibleView(
availableWidth: availableWidth,
data: data,
spacing: spacing,
alignment: alignment,
content: content
)
}
}
}
//_FlexibleView.swift
struct _FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let availableWidth: CGFloat
let data: Data
let spacing: CGFloat
let alignment: HorizontalAlignment
let content: (Data.Element) -> Content
@State var elementsSize: [Data.Element: CGSize] = [:]
var body : some View {
VStack(alignment: alignment, spacing: spacing) {
ForEach(computeRows(), id: \.self) { rowElements in
HStack(spacing: spacing) {
ForEach(rowElements, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
}
func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth
for element in data {
let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]
if remainingWidth - (elementSize.width + spacing) >= 0 {
rows[currentRow].append(element)
} else {
currentRow = currentRow + 1
rows.append([element])
remainingWidth = availableWidth
}
remainingWidth = remainingWidth - (elementSize.width + spacing)
}
return rows
}
}
//SizeReader.swift
extension View {
func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}