解析SwiftUI布局细节(二)循环轮播+复杂布局
前言
上一篇我们总结的主要是VStack里面的东西,由他延伸到 @ViewBuilder, 接着我们上一篇总结的我们这篇内容主要说的是下面的几点,在这些东西说完后我准备解析一下苹果在SiwftUI文档中说道的比较好玩的一个东西,具体的我们后面在看。这篇我们还是说我们关于SwiftUI的东西,再提一下Demo代码我已经提交上Git了,目前Demo进度为一级页面基本上结束,地图点击大头针的添加也刚处理完,代码有需要的小伙伴可以去Git看看,项目地址
1、View之间的跳转(这里有个疑问需要帮忙!)
2、稍微复杂点View的布局思路和一些细节知识
3、SwiftUI循环轮播图
这次总结的首页的UI布局如下,我们下面一点点的解析:
界面跳转的问题
正常的界面跳转逻辑实现是比较简单的,我们先看看这个很简单的正常跳转,再说说我们的问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 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 .. < 3 ){ 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 ) } } } |
参考文章:
理解SwiftUI关键字 State Binding ObservesOgiect EnvironmentObje
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话