背景

日常生活中我们重度使用的 App 当中,基本上都有轮播图,或 Banner、或推荐、或详情介绍、或广告...

轮播图在 App 上显示的位置一般都是很重要的,也是非常引人注目的。所以开发一个项目,轮播图都是不可避免的要被使用到。

Github 上,与 Objective-CSwift 相关的轮播图轮子很多,也非常棒。而 SwiftUI 的却不多。当然你可能会说:用 UIViewRepresentable 封装已有的 Swift 轮子他不香吗? 是的,确实不错。但这并不 SwiftUI。我们想要的是用 SwiftUI 的方式方法去实现。除非最后 SwiftUI 实现不了我们的需求,那就只能选择上面的方法了...

功能实现

想要实现的轮播图需要满足以下几点功能:

  • 分页效果
  • 循环滚动
  • 自动滚动
  • 左右间距
  • 缩放功能

SwiftUI 中,可以实现轮播滚动的大概有以下几种方式:

  1. ScrollView + HStack
  2. TabView + PageTabViewStyle
  3. HStack + DragGesture

下面来逐一分析与实现下:

ScrollView + HStack

基于 Objective-CSwift 的思想,首先想到的就是 ScrollView。 然而很遗憾,SwiftUI 上的 ScrollView 对我们并不太友好,几乎不怎么支持手动控制,即使是在 iOS 14.0 增加了 ScrollViewReaderScrollViewProxy,也无法满足我们的需求。所以这个就弃疗了

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 了。
这里有一个iOS交流圈:891 488 181 可以来了解,分享BAT,阿里面试题、面试经验,讨论技术,裙里资料直接下载就行, 大家一起交流学习!

实现思路是根据 DragGesture 的拖动值来动画改变 HStack 的偏移量 x 值。从而实现滚动效果。

内部视图溢出

当我将基础代码展示出来之后,问题就出现了。

显示在中间的并不是数据源的第一个。看一下层级视图:

我丢。。。 这意味着,我需要计算出默认的 x 偏移量。虽然可以算出来,但是我并不想这样做。我在想有没有其他的办法来解决这个问题。而且我也找到了:GeometryReader

HStack 包裹在 GeometryReader

预览是没问题了。于是我兴致勃勃的在 iOS 13iOS 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 对计时器做了优化,通过 Timerpublish(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 10iOS 9、 甚至还有 iOS 8 的。但这并不影响我们对新版本的学习与适配。面包总会有的。而且,对于一个开发者来说,探索的过程有时候也是一件非常有乐趣的事情。

代码片段放到了这里,希望对你有帮助

我也将功能完善了一下并封装成了 Swift Package Manager,放到了这里。也希望你能给一些意见或者建议。我就不要 Star 了

感谢你的阅读

参考:

作者:JWAutumn
链接:https://juejin.cn/post/6898258968775245837