上一篇文章主要介绍了TabView
的基本用法以及一些外观样式的设置,本篇文章主要介绍一下PageTabViewStyle
样式下的TabView
,该样式下的TabView
允许用户整页滑动界面,在UIKit
中我们用UIScrollView
和UICollectionView
制作滚动组件,本文采用TabView
制作一个无限滚动的组件。
PageTabViewStyle的使用
先看一下效果:
上面是一个简单的整页滚动视图,显示了一组图片,设置TabView
的样式使用下面的修饰符:
.tabViewStyle(PageTabViewStyle())
或者
.tabViewStyle(.page)
设置成PageTabViewStyle
样式的时候可以传入是否要显示指示图标(PageControl)的参数indexDisplayMode
,组件默认是有底部白色的指示图标的,系统已经提供了,还是很方便的。
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
或者
.tabViewStyle(.page(indexDisplayMode: .always))
无限轮播组件
首先定义一个要展示的model类型:
struct Page: Identifiable {
var id: UUID = UUID()
var title: String
}
修改一下上面的代码,给TabView
绑定一个值currentPage
,代码如下:
struct PagedTabViewDemo: View {
@State private var currentPage: UUID = UUID()
@State private var pages: [Page] = []
var body: some View {
VStack {
TabView(selection: $currentPage) {
ForEach(pages) { page in
Image(page.title)
.resizable()
.aspectRatio(contentMode: .fill)
.tag(page.id)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 3 / 5)
Spacer()
}
.onAppear {
for index in 0..<6 {
let page = Page(title: "Image_\(index)")
pages.append(page)
}
}
}
}
代码中给每个Image
设置了一个tag
,tag
的值为Page
的id
,而绑定的currentPage
的类型和Page
的id
为同类型,都为UUID
。
在onAppear
中,创建了要展示的数据,填充pages
数组。
目前的运行效果如下:
本文中要实现无限滚动的主要思想是:在当前数组中插入数据,取第一个元素,修改id
后,插入到数组末尾去,这样就形成了一个新数组。
基于新数组:
当向左滑动到最后一个图片后,再滑动的一瞬间切换到数组的第一个(下标0)元素去显示。
当向右滑动到第一个图片后,再滑动的一瞬间切换到数组的最后一个元素去显示。
下面在onAppear
中插入数据:
// 当前显示页id
@State private var currentPage: UUID = UUID()
// 原始数组
@State private var pages: [Page] = []
// 新数组
@State private var fakedPages: [Page] = []
.onAppear {
// 避免冲入插入数据。
guard fakedPages.isEmpty else { return }
// 初始化原始数据
for index in 0..<6 {
let page = Page(title: "Image_\(index)")
pages.append(page)
}
// 新数组加入原始数据
fakedPages.append(contentsOf: pages)
// 在原始数组中取出第一个元素
if var firstPage = pages.first {
// 将取出的第一个元素的id给currentPage。
currentPage = firstPage.id
// 修改id并插入到新数组的末尾。
firstPage.id = UUID()
fakedPages.append(firstPage)
}
}
有了新数据以及实现思想后,就是如何判断临界条件了,本文通过手动拖动的时候的当前页的偏移量来计算。
下面给View
添加一个扩展方法来计算偏移量:
struct OffsetKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
/// 当addObserver为true的时候计算偏移量,否则不计算。
@ViewBuilder
func offsetX(_ addObserver: Bool, completion: @escaping (CGRect) -> Void) -> some View {
self
.frame(maxWidth: .infinity)
.overlay {
if addObserver {
GeometryReader {
let rect = $0.frame(in: .global)
Color.clear
.preference(key: OffsetKey.self, value: rect)
.onPreferenceChange(OffsetKey.self, perform: completion)
}
}
}
}
}
现在给要显示的每个Image
添加上这个偏移量:
TabView(selection: $currentPage) {
ForEach(fakedPages) { page in
Image(page.title)
.resizable()
.frame(width: size.width, height: size.height)
.aspectRatio(contentMode: .fill)
.tag(page.id)
// 只有当前显示的图片计算偏移量,因此传入currentPage == page.id。闭包中返回偏移数据rect。
.offsetX(currentPage == page.id) { rect in
// 得到图片的偏移量
let minX = rect.minX
}
}
}
下面就是计算两个临界值的判断了,因为需要知道整个组件的宽度,所以TabView
外包裹一层GeometryReader
,并给每个图片设置frame
,具体代码及分析见下面代码:
GeometryReader { geometry in
let size = geometry.size
TabView(selection: $currentPage) {
ForEach(fakedPages) { page in
Image(page.title)
.resizable()
.frame(width: size.width, height: size.height)
.aspectRatio(contentMode: .fill)
.tag(page.id)
// 只有当前显示的图片计算偏移量,因此传入currentPage == page.id。闭包中返回偏移数据rect。
.offsetX(currentPage == page.id) { rect in
// 得到图片的偏移量,向左移动,得到的为负值,向右移动,得到的值为正值。
let minX = rect.minX
print("---> minX: \(minX)")
// 计算TabView的偏移量,越向左移动偏移量越大。只有当在第一页的时候,向右滑动的瞬间,该值为正数,其他情况均为负数。
let pageOffset = minX - (size.width * CGFloat(fakeIndex(page)))
print("---> pageOffset: \(pageOffset)")
// TabView的偏移量除以TabView的宽度,得到一个基于TabView的宽度的偏移倍数。
let pageProgess = pageOffset / size.width
print("---> pageProgess: \(pageProgess)")
// 当在第一页向右滑动的时候,满足该条件,切换到最后一页。
if -pageProgess < 0.0 {
if fakedPages.indices.contains(fakedPages.count - 1) {
currentPage = fakedPages[fakedPages.count - 1].id
}
}
// 当在最后一页向左滑动的时候,满足该条件,切换到第一页。
if -pageProgess > CGFloat(fakedPages.count - 1) {
if fakedPages.indices.contains(0) {
currentPage = fakedPages[0].id
}
}
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 3 / 5)
上面代码中有一个计算在新数组中index
的方法:
// 当前页在新数组中的index。
func fakeIndex(_ page: Page) -> Int {
return fakedPages.firstIndex(where: { $0.id == page.id}) ?? 0
}
效果如下:
上面的代码已经完成了无限滚动功能,因为我们加了数据,所以不能用系统提供的指示图标(PageControl),下面自定义一个PageControl
:
struct PageControl: UIViewRepresentable {
var totalPages: Int
var currentPage: Int
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = totalPages
control.currentPage = currentPage
control.backgroundStyle = .minimal
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.numberOfPages = totalPages
uiView.currentPage = currentPage
}
}
将UIPageControl
封装一下用在SwiftUI
中,这样很多功能以及样式就不需要我们自己去实现了。现在将封装好的PageControl
添加到组件中。
TabView(selection: $currentPage) { ... }
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(alignment: .bottom) {
PageControl(totalPages: pages.count, currentPage: originalIndex(currentPage))
.offset(y: -15)
}
上面代码中有个计算当前页currentPage
在原始数组中的index
的方法:
// 当前pageId在原始数组中的index。
func originalIndex(_ pageId: UUID) -> Int {
return pages.firstIndex(where: { $0.id == pageId}) ?? 0
}
效果如下:
整体看起来效果还不错,到此为止,无限滚动的组件就完成了,整个代码会附在文章末尾。
写在最后
本文主要介绍了TabView
的PageTabViewStyle
样式,一个可以按页滚动的组件,使用起来还是挺简单便捷的。文章内页提供了一个基于TabView
实现的无限滚动的一个组件,当然还有用其他基础组件实现的这个功能,这里就不过多说明了。
最后,希望能够帮助到有需要的朋友,如果您觉得有帮助,还望点个赞,添加个关注,笔者也会不断地努力,写出更多更好用的文章。
完整Demo代码:
//
// PagedTabViewDemo.swift
// SwiftUILearning
//
// Created by GuoYongming on 5/26/24.
//
import SwiftUI
struct Page: Identifiable {
var id: UUID = UUID()
var title: String
}
struct PagedTabViewDemo: View {
// 当前显示页id
@State private var currentPage: UUID = UUID()
// 原始数组
@State private var pages: [Page] = []
// 新数组
@State private var fakedPages: [Page] = []
var body: some View {
VStack {
GeometryReader { geometry in
let size = geometry.size
TabView(selection: $currentPage) {
ForEach(fakedPages) { page in
Image(page.title)
.resizable()
.frame(width: size.width, height: size.height)
.aspectRatio(contentMode: .fill)
.tag(page.id)
// 只有当前显示的图片计算偏移量,因此传入currentPage == page.id。闭包中返回偏移数据rect。
.offsetX(currentPage == page.id) { rect in
// 得到图片的偏移量,向左移动,得到的为负值,向右移动,得到的值为正值。
let minX = rect.minX
print("---> minX: \(minX)")
// 计算TabView的偏移量,越向左移动偏移量越大。只有当在第一页的时候,向右滑动的瞬间,该值为正数,其他情况均为负数。
let pageOffset = minX - (size.width * CGFloat(fakeIndex(page)))
print("---> pageOffset: \(pageOffset)")
// TabView的偏移量除以TabView的宽度,得到一个基于TabView的宽度的偏移倍数。
let pageProgess = pageOffset / size.width
print("---> pageProgess: \(pageProgess)")
// 当在第一页向右滑动的时候,满足该条件,切换到最后一页。
if -pageProgess < 0.0 {
if fakedPages.indices.contains(fakedPages.count - 1) {
currentPage = fakedPages[fakedPages.count - 1].id
}
}
// 当在最后一页向左滑动的时候,满足该条件,切换到第一页。
if -pageProgess > CGFloat(fakedPages.count - 1) {
if fakedPages.indices.contains(0) {
currentPage = fakedPages[0].id
}
}
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.overlay(alignment: .bottom) {
PageControl(totalPages: pages.count, currentPage: originalIndex(currentPage))
.offset(y: -15)
}
}
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 3 / 5)
Spacer()
}
.onAppear {
// 避免冲入插入数据。
guard fakedPages.isEmpty else { return }
// 初始化原始数据
for index in 0..<5 {
let page = Page(title: "Image_\(index)")
pages.append(page)
}
// 新数组加入原始数据
fakedPages.append(contentsOf: pages)
// 在原始数组中取出第一个元素
if var firstPage = pages.first {
// 将取出的第一个元素的id给currentPage。
currentPage = firstPage.id
// 修改id并插入到新数组的末尾。
firstPage.id = UUID()
fakedPages.append(firstPage)
}
}
}
func fakeIndex(_ page: Page) -> Int {
return fakedPages.firstIndex(where: { $0.id == page.id}) ?? 0
}
func originalIndex(_ pageId: UUID) -> Int {
return pages.firstIndex(where: { $0.id == pageId}) ?? 0
}
}
#Preview {
PagedTabViewDemo()
}
struct OffsetKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
extension View {
/// 当addObserver为true的时候计算偏移量,否则不计算。
@ViewBuilder
func offsetX(_ addObserver: Bool, completion: @escaping (CGRect) -> Void) -> some View {
self
.frame(maxWidth: .infinity)
.overlay {
if addObserver {
GeometryReader {
let rect = $0.frame(in: .global)
Color.clear
.preference(key: OffsetKey.self, value: rect)
.onPreferenceChange(OffsetKey.self, perform: completion)
}
}
}
}
}
struct PageControl: UIViewRepresentable {
var totalPages: Int
var currentPage: Int
func makeUIView(context: Context) -> UIPageControl {
let control = UIPageControl()
control.numberOfPages = totalPages
control.currentPage = currentPage
control.backgroundStyle = .minimal
return control
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.numberOfPages = totalPages
uiView.currentPage = currentPage
}
}