背景
日常生活中我们重度使用的 App 当中,基本上都有轮播图,或 Banner、或推荐、或详情介绍、或广告...
轮播图在 App 上显示的位置一般都是很重要的,也是非常引人注目的。所以开发一个项目,轮播图都是不可避免的要被使用到。
Github
上,与 Objective-C
和 Swift
相关的轮播图轮子很多,也非常棒。而 SwiftUI
的却不多。当然你可能会说:用 UIViewRepresentable
封装已有的 Swift
轮子他不香吗? 是的,确实不错。但这并不 SwiftUI
。我们想要的是用 SwiftUI
的方式方法去实现。除非最后 SwiftUI
实现不了我们的需求,那就只能选择上面的方法了...
功能实现
想要实现的轮播图需要满足以下几点功能:
- 分页效果
- 循环滚动
- 自动滚动
- 左右间距
- 缩放功能
SwiftUI
中,可以实现轮播滚动的大概有以下几种方式:
ScrollView + HStack
TabView + PageTabViewStyle
HStack + DragGesture
下面来逐一分析与实现下:
ScrollView + HStack
基于 Objective-C
和 Swift
的思想,首先想到的就是 ScrollView
。
然而很遗憾,SwiftUI
上的 ScrollView
对我们并不太友好,几乎不怎么支持手动控制,即使是在 iOS 14.0
增加了 ScrollViewReader
和 ScrollViewProxy
,也无法满足我们的需求。所以这个就弃疗了
TabView + PageTabViewStyle
PageTabViewStyle
也是 iOS 14
的产物。
实现方法很简单:
import SwiftUI
let width = UIScreen.main.bounds.width
struct ContentView: View {
let colors: [Color] = [.red, .blue, .green, .pink, .purple]
@State private var selection: Color = .red
var body: some View {
TabView(selection: $selection) {
ForEach(colors, id: \.self) {
$0.tag($0)
.frame(width: width - 20, height: $0 == selection ? 200 : 150)
}
}
.frame(height: 200)
.tabViewStyle(PageTabViewStyle())
.animation(.spring())
}
}
当我实现到这里的时候,几乎已经确定了这就是我需要的效果了,毕竟上面所需要的功能基本都实现了。再加上一个计时器就可以收工了。
然而,我还是年轻了。当我尝试加循环滚动的时候,我愣住了。
循环滚动的思路是:判断当前位置是第一页的时候,跳转到倒数第二页;同理,当前位置为最后一页时,跳转到第二页。
思路是可以,可是却无法控制 PageTabViewStyle
的切换动画。所以导致了上图的状况。
如果只是需要简单的轮播效果,那么 TabView + PageTabViewStyle
可以满足需求。
HStack + DragGesture
上面两种方式都无法满足需求,那就只能考虑自定义 HStack
了。
实现思路是根据 DragGesture
的拖动值来动画改变 HStack
的偏移量 x
值。从而实现滚动效果。
内部视图溢出
当我将基础代码展示出来之后,问题就出现了。
显示在中间的并不是数据源的第一个。看一下层级视图:
我丢。。。 这意味着,我需要计算出默认的 x
偏移量。虽然可以算出来,但是我并不想这样做。我在想有没有其他的办法来解决这个问题。而且我也找到了:GeometryReader
将 HStack
包裹在 GeometryReader
内
预览是没问题了。于是我兴致勃勃的在 iOS 13
和 iOS 14
模拟器上分别测试了一下:
此时,我已经不想写下去了。😡😤😭
Google 一番后找到了原因:
GeometryReader 改变了它显示内容的方式。在 iOS 13.5 中,内容放置方式为 .center。在 iOS 14.0 中则为:.topLeading
对应的解决办法:
struct ContentView: View {
let colors: [Color] = [.red, .blue, .green, .pink, .purple]
var body: some View {
let width = UIScreen.main.bounds.width - 40
GeometryReader { proxy in
HStack(alignment: .center, spacing: 10) {
ForEach(colors, id: \.self) { color in
color
.frame(width: width)
}
}
.frame(width: proxy.size.width,
height: proxy.size.height,
alignment: .leading) /// 重要,必须实现
}
.frame(height: 200)
}
}
模拟器效果:
苹果啊,你差点就失去了一个开发者,感谢谷歌吧你就!
添加 DragGesture
单独创建一个分类实现 DragGesture
:
extension ContentView {
private var dragGesture: some Gesture {
DragGesture()
.onChanged { changeValue in
dragOffset = changeValue.translation.width
}
.onEnded { endValue in
dragOffset = .zero
/// 拖动右滑,偏移量增加,显示 index 减少
if endValue.translation.width > 50 {
currentIndex -= 1
}
/// 拖动左滑,偏移量减少,显示 index 增加
if endValue.translation.width < -50 {
currentIndex += 1
}
/// 防止越界
currentIndex = max(min(currentIndex, colors.count - 1), 0)
}
}
}
结构体中定义了两个参数:
@State var dragOffset: CGFloat = .zero
/// 当前显示的位置索引
@State var currentIndex: Int = 0
而后根据这些数据计算出 x
的偏移量。从而动画实现移动
let width = UIScreen.main.bounds.width - 40
let spacing: CGFloat = 10
/// 单个子视图偏移量 = 单个视图宽度 + 视图的间距
let currentOffset = CGFloat(currentIndex) * (width + spacing)
...
HStack(alignment: .center, spacing: spacing) { ... }
.offset(x: dragOffset - currentOffset)
.gesture(dragGesture)
.animation(.spring())
如果每个子视图的偏移量为
width
,那么拖动几次后,上一个视图还是会显示在当前窗口中, 所以每个子视图的偏移量都要加上他的spacing
。 这样每次拖动后,原先的子视图一定会被隐藏,新的也会全部展示出来 而当前位置的偏移量就等于:当前显示的索引 ✖️ 偏移量
循环滚动
循环滚动之前,需要先给第一个子视图添加左边距,这样就能看到当前子视图的上一个和下一个视图:
let defaultPadding: CGFloat = 20
...
HStack(...) { ... }
.offset(x: defaultPadding + dragOffset - currentOffset)
按照我们上面写的思路:当前位置是第一页的时候,跳转到倒数第二页;同理,当前位置为最后一页时,跳转到第二页。
需要重新修改一下参数 colors
let colors: [Color] = [.red, .blue, .green, .pink, .purple]
var loopColors: [Color] {
return [colors.last!] + colors + [colors.first!]
}
···
同样也要将之前用到参数 colors
替换成 loopColors
。
接下来,通过 iOS 14
新增的 modifier .onChange()
来监听参数 currentIndex
的变化。
同时也要加一个 Bool
值来改变滚动动画:currentIndex
达到循环临界值的时候隐藏动画,不需要的时候再开启动画,解决上面的问题:无法控制 PageTabViewStyle
的切换动画
.onChange(of: currentIndex, perform: { value in
isAnimation = true
if value == 0 {
currentIndex = loopColors.count - 2
isAnimation.toggle()
} else if value == loopColors.count - 1 {
currentIndex = 1
isAnimation.toggle()
}
})
上面的前提是参数
colors
数量要大于 1,否则循环就没啥意义了
别忘了要在拖动手势的地方把参数 isAnimation
设置为 true
,因为上面的 .onChange()
也只是监听当前索引的变化,拖动的过程中也是需要动画的,不然的话会稍微有点别扭
...
DragGesture()
.onChanged { changeValue in
isAnimation = true
dragOffset = changeValue.translation.width
}
...
搞定!
那么,请问:怎么适配 iOS 13
呢? 答:请你走开
iOS 13
中通过引入 Combine
+ .onReceive()
也可以实现监听效果:
定义一个 class
,遵守 ObservableObject
协议
class StateModel: ObservableObject {
@Published var currentIndex: Int = 1
}
在结构体中初始化
/// 当前显示的位置索引
// @State var currentIndex: Int = 1
@ObservedObject var state = StateModel()
并用 state.currentIndex
替换掉原先所有的 currentIndex
,删除掉 .onChange()
,加上 onReceive()
。
.onReceive(state.$currentIndex, perform: { value in
isAnimation = true
if value == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
state.currentIndex = loopColors.count - 2
isAnimation.toggle()
}
} else if value == loopColors.count - 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
state.currentIndex = 1
isAnimation.toggle()
}
}
})
这里加了个延迟加载,如果不加的话,会不起作用,动画会一直存在,不会消失。
至于为什么是这样,如果你知道,请告诉我。谢谢!
运行后效果跟上面一样。也实现了循环滚动效果。
缩放效果
缩放效果:当前视图为正常高度,上一个和下一个视图的高度缩放一定比例。 思路就是找到正在显示的子视图。
根据现有的 demo,实现一个 Color
的拓展:
extension Color: Identifiable {
public var id: Color {
self
}
}
然后处理 HStack
里面子视图的高度
...
HStack(alignment: .center, spacing: spacing) {
ForEach(loopColors, id: \.self) { color in
color.frame(width: width)
.frame(height: color.id == loopColors[state.currentIndex].id ? proxy.size.height : proxy.size.height * 0.8)
}
}
...
这里如果有两个相同的颜色排列,则都不会缩放,以为他们的 id 是相同的。
这里只是提供一个实现方法。并没有对 demo 具体优化。有那个意思就行。
到这里就实现了缩放功能。
自动滚动
通过计时器 Timer
实现。
SwiftUI
对计时器做了优化,通过 Timer
的 publish(every:, on:, in:)
方法将其转换成 Publisher
,然后通过 .onReceive()
modifier 实现监听。
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
...
HStack(...)
...
.onReceive(timer, perform: { _ in
state.currentIndex += 1
})
计时器里只需要实现 currentIndex += 1
就行了,.onReceive(state.currentIndex, ...)
里会自动计算 currentIndex
的最终值。
这里只是实现了基础功能,具体实现的时候你还需要考虑一些其他因素:
- 拖动过程中,自动滚动要关闭,拖动结束后,重新开启自动滚动
- 考虑应用的生命周期,App 进入后台要暂停计时器,进入前台后再继续。
自动滚动功能也就实现了。
小结
写到这里,轮播图基本成型了。我们在最开始提出的功能大多都实现了。剩下的就是把每一个步骤进一步的优化与测试了。
总结
每个产品的需求都是不同的,一个基本的轮播图并不能适用于所有的 App。这里也只是给出了实现思路,你可以按照这个思路,在自己的项目中进一步的开发与完善。
SwiftUI
的优点就是让开发变得更快速,也就让我们有更多的时间来思考用户体验的问题。从而开发出更好的产品出来。尽管 SwiftUI
仅支持 iOS 13
及以上系统,而大多数应用的最低版本都是 iOS 10
、iOS 9
、 甚至还有 iOS 8
的。但这并不影响我们对新版本的学习与适配。面包总会有的。而且,对于一个开发者来说,探索的过程有时候也是一件非常有乐趣的事情。
代码片段放到了这里,希望对你有帮助
我也将功能完善了一下并封装成了 Swift Package Manager
,放到了这里。也希望你能给一些意见或者建议。我就不要 Star 了
感谢你的阅读
参考: