扬庆の博客

SwiftUI-TagsView布局Layout

字数统计: 1.2k阅读时长: 5 min
2024/04/08 Share

SwiftUI-TagsView布局Layout

Layout

在SwiftUI中,Layout协议不是公共API的一部分,而是用于内部实现的。它用于定义如何布局视图的实际算法。开发者通常不会直接与Layout协议进行交互,而是通过使用SwiftUI提供的布局系统来实现自定义布局。

SwiftUI的布局系统是基于声明式的,它使用View协议来定义视图的外观和行为,而不是直接控制布局算法。在开发者创建自定义布局时,他们通常会通过实现View协议中的body属性来创建自己的视图,并使用SwiftUI提供的布局容器(例如VStackHStackZStack等)来组织和排列子视图。

虽然Layout协议不是公开的,但是SwiftUI提供了一些用于自定义布局的公共API,例如GeometryProxyViewModifier。通过这些API,开发者可以在自定义布局中访问父视图的几何信息,并应用布局修饰符来控制视图的外观和行为。

总的来说,虽然Layout协议在SwiftUI中起着重要作用,但是大多数开发者不需要直接与它交互。相反,他们可以使用SwiftUI提供的高级API来实现自定义布局和视图。

Layout 协议必须实现的两个方法

Layout协议必须实现的两个方法

算出多少组每组哪些元素

算出多少组每组哪些元素

扩展: 计算 [每组] 元素的最大的高度

扩展-每组计算最大高度

上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import SwiftUI

struct TagLayout: Layout {
/// 对齐方式
var alignment: Alignment = .trailing
/// 间距
var spacing: CGFloat = 0


// 大小: 重点在于: .init(width: maxWidth, height: height)
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth: CGFloat = proposal.width ?? 0
var height: CGFloat = 0
let rows = generateRows(maxWidth, proposal, subviews: subviews)

for(index, row) in rows.enumerated() {
if index == rows.count - 1 {
height += row.maxHeight(proposal)
} else {
height += spacing + row.maxHeight(proposal)
}
}

return .init(width: maxWidth, height: height)
}

// 位置: 重点在于设置 view.place(at: origin, proposal: proposal)
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let maxWidth: CGFloat = bounds.width
var origin: CGPoint = bounds.origin
let rows = generateRows(maxWidth, proposal, subviews: subviews)

for row in rows {
let leading: CGFloat = bounds.maxX - maxWidth
let trailing = bounds.maxX - row.reduce(CGFloat.zero, { partialResult, view in
let width = view.sizeThatFits(proposal).width
if view == row.last {
return partialResult + width
}
return partialResult + width + spacing
})

let center = (trailing + leading) * 0.5

origin.x = alignment == .leading ? leading : (alignment == .trailing ? trailing : center)

for view in row {
let viewSize = view.sizeThatFits(proposal)
view.place(at: origin, proposal: proposal)
origin.x += viewSize.width + spacing
}
origin.y += row.maxHeight(proposal) + spacing
}
}
}

extension TagLayout {
/// 生成 [组] --> 基于可用尺寸
func generateRows(_ maxWidth: CGFloat, _ proposal: ProposedViewSize, subviews: Subviews) -> [[LayoutSubviews.Element]] {

var row: [LayoutSubviews.Element] = []
var rows: [[LayoutSubviews.Element]] = []

// 注意📢: 如何分组: 关键在这个 origin.x 的判断?
var origin = CGRect.zero.origin

for view in subviews {
/// 子视图大小, 根据父视图 container 确定。
let viewSize = view.sizeThatFits(proposal)

if (origin.x + viewSize.width + spacing) > maxWidth {
print("另起一行")
// 另起一行
rows.append(row)
row.removeAll()

origin.x = 0
row.append(view)
origin.x += (viewSize.width + spacing)
} else {
// 同一行
row.append(view)
origin.x += (viewSize.width + spacing)
}
}

// 由于最后一行, 未达到 >maxWidth条件, 所以未加入到 rows 数组里.[手动添加]
if !row.isEmpty {
rows.append(row)
row.removeAll()
}
return rows
}
}

// 扩展: 每组最大高度, reduce高阶函数将序列,转化为一个元素
extension [LayoutSubviews.Element] {

/// 取出每组最大高度
func maxHeight(_ proposal: ProposedViewSize) -> CGFloat {
return self.compactMap { view in
return view.sizeThatFits(proposal).height
}.max() ?? 0
}
}

动画

修饰符: matchedGeometryEffect

在SwiftUI中,matchedGeometryEffect修饰符用于在视图之间创建动画效果,以便使两个视图之间的几何形状(例如位置、大小、旋转等)匹配。这在创建复杂的用户界面时非常有用,尤其是在实现页面转换或视图交互时。

当你在两个视图之间使用matchedGeometryEffect时,SwiftUI会自动计算两个视图之间的差异,并在它们之间创建平滑的动画以使它们匹配。这个修饰符通常与matchedGeometryEffectId一起使用,以便SwiftUI知道如何匹配两个视图。

例如,你可以在两个不同的界面之间传递相同的标识符来创建动画效果,使得一个视图在界面转换时平滑地转换到另一个视图的位置和大小。

以下是一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct ContentView: View {
@State private var isExpanded = false

var body: some View {
VStack {
if isExpanded {
RoundedRectangle(cornerRadius: 25.0)
.matchedGeometryEffect(id: "shape", in: namespace)
.frame(width: 200, height: 200)
} else {
RoundedRectangle(cornerRadius: 10.0)
.matchedGeometryEffect(id: "shape", in: namespace)
.frame(width: 100, height: 100)
}
}
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
}
}

在这个示例中,当你点击视图时,它会在大小和位置之间平滑地过渡,因为我们在两个状态之间使用了matchedGeometryEffect修饰符,并且它们都有相同的标识符。

Demo: TagView

平滑过度动画效果

实现部分-穿透的动画效果

TagView

运行效果

运行效果.gif

CATALOG
  1. 1. SwiftUI-TagsView布局Layout
    1. 1.1. Layout
    2. 1.2. 上代码
    3. 1.3. 动画
    4. 1.4. Demo: TagView
      1. 1.4.1. TagView
    5. 1.5. 运行效果