解析SwiftUI布局细节,循环轮播+复杂布局
在前面的文章中谈了谈对SwiftUI的基本的认识,以及用我们最常见的TB+NA的方式搭建了一个很基本的场景来帮助认识了一下SwiftUI,具体的文章可以在SwiftUI分类部分查找,这篇我准备在写UI的时候从SwiftUI角度我们具体的应该怎样去做,或者说是用SwiftUI我们该从什么角度去解析一个页面。以及对SwiftUI里面的其中一些细节知识做一下分析总结。
以前我们用UIKit写一个列表页的时候我们的步骤可能是下面这样的:
1、创建视图控制器
2、大概解析一下UI,该创建头部的创建头部视图,该写CollectionViewCell或者TableViewCell的我们会做一个基本的分类,规划一下我们需要几个类型的Cell等等
3、把它们进行一个组装,处理相应的各种代理或者事件回调等等
4、处理数据和视图进行数据对接
可能我们大部分都是这样的一个基本的流程,当然还有些涉及到复杂点的业务我们会从单元测试开始等等的会有些许差异,但SwiftUI的重点是对UI的处理,所以我们的重点就单纯说说UI部分,那大家可以这样想,我们用SwiftUI做的时候该怎样去开始呢,用SwiftUI做的时候流程还会和我们使用UIKit处理的时候还一样吗?在实现的细节方面又会有哪些差距呢?带着这样一个小小的思考我们进行下面的总结。
SwiftUI我们怎么做以及细节分析
前面文章我有提过一点就是View,SwiftUI最大的区别除了声明式的UI之外我自己觉得最大的需要我们理解的点就是View,所有的你能看到的基本单位都成了View,没有了控制器这个概念,这点需要我们转过这个弯,不然容易绕进去。
我们从一个具体的实际页面开始梳理一下用SwiftUI实际写UI的时候一些基本的知识,就如我们Demo中的我的页面举例:
我们首先得认识一下它俩:VStack (竖直) HStack (横向)
它们俩我最能接受的方式就是把他们理解成容器(受Cocos影响),一个纵向 (vertical) 容器,一个横向(horizontal)容器,它们前面的V和H也就是这两单词的首字母,提醒一下你要是记不住的话可以记这一点。H(heng) 剩下的V就是纵向的,所有的iOS方向属性几乎都是这样,加深记忆的一个方式而已,但能保证你以后绝不会再搞混淆! 当然这个横向和纵向也是相对你手机屏幕的是竖直还是水平的,不是绝对的,这个理解一下也容易!由于这两里面的东西几乎都是一样的,我们就针对一个VStack进行具体的分析,先看看它的源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// A view that arranges its children in a vertical line.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct VStack : View where Content : View {
/// Creates an instance with the given spacing and horizontal alignment.
///
/// - Parameters:
/// - alignment: The guide for aligning the subviews in this stack. It has
/// the same horizontal screen coordinate for all children.
/// - spacing: The distance between adjacent subviews, or `nil` if you
/// want the stack to choose a default distance for each pair of
/// subviews.
/// - content: A view builder that creates the content of this stack.
@inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
/// The type of view representing the body of this view.
///
/// When you create a custom view, Swift infers this type from your
/// implementation of the required `body` property.
public typealias Body = Never
}
我们解释一下它初始化的方法参数:
1、首先我们要认识到VStack是一个结构体
2、alignment: HorizontalAlignment 我们可以看到它有一个默认的居中对齐值,它控制的就是容器里面的子视图的对齐方式,这个可以自己体验下。
3、spacing: CGFloat? = nil 这是个可选类型的参数,它控制的是容器里面子视图之间的间距。
4、@ViewBuilder content: () -> Content 这是一个很有意思的东西,很值得我们仔细的说说,因为我们在后面会经常使用到这个@ViewBuilder,要暂时不管它那这个参数就只剩下content: () -> Content部分,这个闭包相信都能理解,一个比较简单的闭包,对Content 的约束都在声明VStack的时候说的比较清楚。那他和普通的闭包区别也就在@ViewBuilder上,我们就把重点转移到对@ViewBuilder的理解上了。
下面是关于ViewBuilder的定义:
1
2
3
4
5
6
7
8
9
10
11
12
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@_functionBuilder public struct ViewBuilder {
/// Builds an empty view from a block containing no statements.
public static func buildBlock() -> EmptyView
/// Passes a single view written as a child view through unmodified.
///
/// An example of a single view written as a child view is
/// `{ Text("Hello") }`.
public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}
这里面最值得注意点就是这个 @_functionBuilder 修饰符,_functionBuilder实质上能对函数进行一次处理,具体的我们可以看看下面的例子:
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
/// 用_functionBuilder修饰TestBuilder
/// 就像用_functionBuilder修饰了ViewBuilder一样
/// 我们就用TestBuilder看看它的实际效果
@_functionBuilder struct TestBuilder {
/// String... 参数 数量可变,你可以传入任意数量的参数
/// - Parameter items: items description
/// - Returns: description
static func buildBlock(_ items: String...) -> [String] {
return items
}
}
/// 然后我们有这样一个方法
/// @TestBuilder模拟@ViewBuilder
/// - Parameter content: content description
func testBuilder(@TestBuilder _ content:() -> [String]){
print(content())
}
/// 然后我们调用的时候
self.testBuilder {
“1”
“2”
“3”
“4”
}
随后的打印结果就是 [“1”, “2”, “3”, “4”]
那下面我们理解一下这个例子,在整个显式的调用中,我们似乎是没有用到buildBlock函数的,那要是我们在定义TestBuilder的时候要是不定义buildBlock是不是也可以,当然是不行的,这个在具体的例子中可以试试,在调用的时候就会报错,告诉你没有buildBlock函数,这个函数的具体的作用,我们在对它的注释中能找到答案。
Builds an empty view from a block containing no statements.
可以简单翻译成-从不包含任何语句的块中生成空视图。那我们就明白了,它的作用感觉类似初始化的样子,要没有它就显然是不行的。
还有上面我们调用的时候为什么要写成列的形式,能不能写成"1" "2" "3" "4" 这种形式呢?肯定是不行的,这个你也可以自己尝试一下。
我们要再往深入挖掘一下,因为后面还有个问题需要我们注意,在ViewBuilder的最后一个Extension中的buildBlock的代码是这样的
1
2
3
4
5
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder {
public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}
由于它里面最多能接收10个View,所以在我们常见的Stack中也就最多能接收到是个子视图,这点需要我们注意,不要到时候写的超过十个了然后一头雾水不知道是啥错误。接着我们肯定会疑惑,那就没有办法写是个以上的子视图了吗?答案当然是不是,肯定可以,具体的可以通过Group或者ForEach来实现,我们就不在往下深究了,这个问题可以自己看看!
不知道看到这大家对ViewBuilder应该有了一些认识了吧,我会在后面的参考文章中具体的在给几个例子地址,大家可以再仔细的看看,我们就看我们Demo中的一个使用,他具体的一个场景是这样的,在登录页面,我想加一个点击除了输入框之外收起键盘的操作,我们具体的实现方法其实就是在最底层添加了一个View,然后在它上面添加了点击的手势,具体得我们看看代码:
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
/// 定义一个常见的背景View
struct Background<Content: View>: View {
private var content: Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
Color.white
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
.overlay(content)
}
}
/// UIApplication 的扩展
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
/// 具体的使用就是下面这样,这样就达到了我们的目的,中间的代码我隐藏起来了,代码在BaseLoginView中可以查看到
///
var body: some View {
Background {
/// 里面具体的视图内容
}.onTapGesture {
self.endEditing()
}
}
这样我相信就基本把这个比较重要的@ViewBuilder给说清楚了,这个VStack或者HStack也就应该慢慢的再理解了。
理解了之后我们也就能总结一下我们用SwiftUI写UI时候的一个简单逻辑
1、创建好你需要的SwiftUI文件
2、规划好你的视图层级,比如说是不是嵌套的NavigationView里面,然后开始规划Stack,看具体的是需要规划成几个你需要的Stack
3、再往下就是里面具体的各种控件View了,我打算把他们放到下一篇再做一个具体的总结
下一篇我们就说说SwiftUI关于View跳转的方式,以及传值注意点、View位置设置、大小缩放等等的属性的使用
我们总结的主要是VStack里面的东西,由他延伸到 @ViewBuilder, 接着我们上一篇总结的我们这篇内容主要说的是下面的几点,在这些东西说完后我准备解析一下苹果在SiwftUI文档中说道的比较好玩的一个东西,具体的我们后面在看。这篇我们还是说我们关于SwiftUI的东西,再提一下Demo代码我已经提交上Git了,目前Demo进度为一级页面基本上结束,地图点击大头针的添加也刚处理完,代码有需要的小伙伴可以去Git看看,项目地址
1、View之间的跳转(这里有个疑问需要帮忙!)
2、稍微复杂点View的布局思路和一些细节知识
3、SwiftUI循环轮播图
这次总结的首页的UI布局如下,我们下面一点点的解析:
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
http://www.
界面跳转的问题
正常的界面跳转逻辑实现是比较简单的,我们先看看这个很简单的正常跳转,再说说我们的问题:
添加链接描述
NavigationView{
VStack{
List{
/// 开关按钮
/// Toggle(isOn: $userData.showFavoritesOnly) {Text(“Favorites only”)}
ForEach(landmarkData) { landmark in
if !self.userData.showFavoritesOnly || landmark.isFavorite {
NavigationLink(destination:LandmarkDetail(landmark:landmark)
.environmentObject(self.userData),label:{
LandmarkRow(landmark: landmark)
})
}
}
}
.listStyle(PlainListStyle())
.navigationTitle("iPhone")
}
}
这是一个很普通的通过 NavigationView + NavigationLink 的界面跳转,在苹果给的 SwiftUI 的使用例子中就是这样写的,当然我们在正常的使用中这样写也没啥问题,那我们界面跳转的问题是什么呢?
如果你看了我们 Demo中的代码,你就知道我们是采用 TabView 嵌套 NavigationView 的形式,在这样的模式下似乎是存在问题的, 在 TabView+NavigationView 中你利用 NavigationLink 单击没法跳转,只有长按的时候才能跳转,这个问题抛出来,有懂得小伙伴希望能给我说一下,这个问题我也一直没有解决!具体的我们Demo中可以看看“我的”页面那个 List 的代码,问题就在那里。要理解这点的麻烦也给我说说,感谢!
首页布局
我们把首页这个布局给解析一下,大概分了下面几部分,我们再具体的说说:
我们看看最底层的代码先:
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
NavigationView{
ScrollView(showsIndicators:false,content: {
/// Banner视图
HomeBannerView()
.environmentObject(homeViewModel)
/// 服务列表
HomeServiceCircleView().frame(
width: homeViewModel.homeServiceCircleWidth,
height: homeViewModel.homeServiceCircleHeight)
.environmentObject(homeViewModel)
.offset(y: -5)
/// 滚动头条
HomeCircleNewsView().frame(
width: homeViewModel.homeNewsCircleWidth,
height: homeViewModel.homeNewsCircleHeight)
.environmentObject(homeViewModel)
/// 四个按钮
HomeButtonView().frame(
width: homeViewModel.homeButtonViewWidth,
height: homeViewModel.homeButtonViewHeight)
.offset(y: -5)
/// 服务列
HomeServiceListView().frame(
width: homeViewModel.homeServiceViewWidth,
height: homeViewModel.homeServiceViewHeight)
.environmentObject(homeViewModel)
/// 最美的风景
HomeSnapshotView().environmentObject(homeViewModel)
}).navigationTitle(title)
}
这部分的代码没有啥特别需要说明的,都比较简单,可能是就是这个 environmentObject (我把它称为环境变量)这个是需要特别说明的一个变量,从名字上可以看出,这个修饰符是针对全局环境的。通过它我们可以避免在初始 View 时创建 ObservableObject, 而是从环境中获取 ObservableObject,像 @EnvironmentObject,@ObservedObject,@Binding 和 @States 这几个关键字还是需要需要我们特别理解的。下面这篇我们博客园的同行总结的还是很精辟的。传送门在这
下面是我们值得细说的一些点:
1、值得注意的 TabView + PageTabViewStyle
这是在iOS14中新出的一个值得我们注意的点,PageTabViewStyle 是14.0的新东西,但它的确能达到一个满意的翻页效果。和我们UIKit中的效果一样。具体的代码如下:
1
2
3
4
5
6
7
8
9
10
11
TabView(selection: $selection) {
/// 里面的具体内容,我们写了三页
ForEach(0…❤️){
HomeServicePageView(pageIndex: $0)
.tag($0)
.environmentObject(homeViewModel)
}
}
/// PageTabViewStyle 14.0的新东西
.tabViewStyle(PageTabViewStyle())
.animation(.spring())
2、GeometryReader 它其实是有必要好好了解一下的。GeometryReader 的主要作用就是能够获取到父View建议的尺寸,这就是它的主要作用,要没有它我们面临的可能就是无休止的传值了,SwiftUI 既然是声明式的UI,按我的理解你就没有办法去获取某一个视图的父视图之类的。不然怎么体现声明这个点呢!
这个GeometryReader在前面第一期的时候我说过这个属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// A proxy for access to the size and coordinate space (for anchor resolution)
/// of the container view.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct GeometryProxy {
/// The size of the container view.
public var size: CGSize { get }
/// Resolves the value of `anchor` to the container view.
public subscript<T>(anchor: Anchor<T>) -> T { get }
/// The safe area inset of the container view.
public var safeAreaInsets: EdgeInsets { get }
/// Returns the container view's bounds rectangle, converted to a defined
/// coordinate space.
public func frame(in coordinateSpace: CoordinateSpace) -> CGRect
}
* size 比较直观,就是返回父View建议的尺寸
** subscript 可以让我们获取.leading,.top等等类似这样的数据
*** safeAreaInsets 可以获取安全区域的Insets
**** frame(in:) 要求传入一个CoordinateSpace类型的参数,也就是坐标空间,可以是.local, .global 或者 .named(),其中 .named()可以自定义坐标空间。
有一个还得说明一下,GeometryReader 改变了它显示内容的方式。在 iOS 13.5 中,内容放置方式为 .center。在 iOS 14.0 中则为:.topLeading。
3、再提一点关于上面说的滚动视图,在UIKit中我们可以用UICollectionView搞定一切,但是在SwiftUI中没有这个控件,我建议采用的方式是 ScrollView + HStack + VStack 的方式去实现,很多同行有说目前来看SwiftUI的List在数据量大的情况下性能不是特别好,采用ScrollView是个不错的方式,而且也很容易构建出来,并不是说每一个Item的位置都需要你去计算,所以没啥可以担心的。
除了这个List,还要一个From我们也可以了解下,他们俩肉眼可见的区别 在选中这个点上的区别。
循环轮播实现
总结一下循环轮播怎么实现,采用的方案就是 HStack + Gesture + Timer 的方式,这三者就能实现一个自动循环滚动或者手动滚动的轮播。然后缩放的方式还是比较简单的,我们采用改变下Image的frame的方式。
HStack 这没啥可以具体说的,可以看代码,注释比较多,就不在这里累赘了。
Gesture 这个我们可以说说,它就是我们具体手势的父类,像我们的单击手势和我们这里用到的拖拽手势一样。具体的我们会看下面的代码,他们的区别就是像拖拽我们可以监控它的改变状态,点击或者双击、长按等我们可以添加事件等等。下面是拖拽的代码:
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
/// 定义拖拽手势
private var dragGesture: some Gesture{
DragGesture()
/// 拖动改变
.onChanged {
isAnimation = true
dragOffset = $0.translation.width
}
/// 结束
.onEnded {
dragOffset = .zero
/// 拖动右滑,偏移量增加,显示 index 减少
if $0.translation.width > 50{
currentIndex -= 1
}
/// 拖动左滑,偏移量减少,显示 index 增加
if $0.translation.width < -50{
currentIndex += 1
}
/// 防止越界
currentIndex = max(min(currentIndex, homeViewModel.homeBannerCount() - 1), 0)
}
}
再看看Timer,SwiftUI区别于我们UIKit的创建方式,SwiftUI对它进行了简化,具体的创建如下:
1
2
/// SwiftUI对定时器的简化,可以进去看看具体参数的定义
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
它不像我们UIKit的需要我们绑定事件,那它的事件是怎么处理的呢?看看下面的代码:
1
2
3
4
/// 对定时器的监听
.onReceive(timer, perform: { _ in
currentIndex += 1
}
它的事件就是通过 onReceive 监听处理的,所有通过 publish 创建的都是可以通过 onReceive 监听的。那还有啥事通过 publish 创建的呢?我所用到的就是 NotificationCenter。
这样基本上循环轮播的实现我们基本上都说清楚了,具体里面的一些实现细节代码注释写的清清楚楚,还是仔细看看代码结合里面的注释来看,难度不是很大。首页顶部自动循环轮播的代码实现如下,代码里有些注释还是比较重要的,注意看注释:
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
106
107
struct HomeBannerView: View {
@EnvironmentObject var homeViewModel: HomeViewModel
/// SwiftUI 对定时器的简化,可以进去看看具体参数的定义
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
/// 拖拽的偏移量
@State var dragOffset: CGFloat = .zero
/// 当前显示的位置索引,
/// 这是实际数据中的1就是数据没有被处理之前的0位置的图片
/// 所以这里默认从1开始
@State var currentIndex: Int = 1
/// 是否需要动画
@State var isAnimation: Bool = true
let spacing: CGFloat = 10
var body: some View {
/// 单个子视图偏移量 = 单个视图宽度 + 视图的间距
let currentOffset = CGFloat(currentIndex) * (homeViewModel.homeBannerWidth + spacing)
/// GeometryReader 改变了它显示内容的方式。在 iOS 13.5 中,内容放置方式为 .center。在 iOS 14.0 中则为:.topLeading
GeometryReader(content: { geometry in
HStack(spacing: spacing){
ForEach(0..<homeViewModel.homeBannerCount()){
/*
如果想自定义Image大小,可以添加frame
clipped()相当于UIKit里的clipsToBounds,
与aspectRatio(contentMode: .fill)搭配使用。
注意:frame 要放在resizable后面,否则报错,
如果需求裁剪,需要放在aspectRatio后面,
clipped()前面,否则frame失效 */
Image(homeViewModel.bannerImage($0)).resizable()
/// 自己尝试一下.fill和.fit
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width,
height: $0 == currentIndex ? geometry.size.height:geometry.size.height*0.8 )
.clipped() /// 裁减
.cornerRadius(10)
}
}.frame(width:geometry.size.width,
height:geometry.size.height,alignment:.leading)
.offset(x: dragOffset - currentOffset)
.gesture(dragGesture)
/// 绑定是否需要动画
.animation(isAnimation ?.spring():.none)
/// 监听当前索引的变化,最开始初始化为0是不监听的,
.onChange(of: currentIndex, perform: { value in
isAnimation = true
/// 第一张的时候
if value == 0 {
isAnimation.toggle()
currentIndex = homeViewModel.homeBannerCount() - 2
/// 最后一张的时候currentIndex设置为1关闭动画
}else if value == homeViewModel.homeBannerCount() - 1 {
isAnimation.toggle()
currentIndex = 1
}
})
/// 对定时器的监听
.onReceive(timer, perform: { _ in
currentIndex += 1
})
}).frame(width: homeViewModel.homeBannerWidth,
height: homeViewModel.homeBannerHeight)
}
}
// MARK: -
extension HomeBannerView{
/// 定义拖拽手势
private var dragGesture: some Gesture{
DragGesture()
/// 拖动改变
.onChanged {
isAnimation = true
dragOffset = $0.translation.width
}
/// 结束
.onEnded {
dragOffset = .zero
/// 拖动右滑,偏移量增加,显示 index 减少
if $0.translation.width > 50{
currentIndex -= 1
}
/// 拖动左滑,偏移量减少,显示 index 增加
if $0.translation.width < -50{
currentIndex += 1
}
/// 防止越界
currentIndex = max(min(currentIndex, homeViewModel.homeBannerCount() - 1), 0)
}
}
}