源代码:https://github.com/chr1s78/MultipleColumnsListSwiftUI/tree/main
尝试手动实现一个SwiftUI的多列列表,效果如下:

在列表的标题滚动到顶部时,显示对应的标题内容,并固定,实现此方法需要用到?preference 首选项功能。关于preference,可以参阅这篇文章?The magic of view preferences in SwiftUI
简单来说,preference允许我们将子视图的属性,传递给父视图。在这个例子中,我们将标题栏的位置和大小,传递给父视图,由父视图来判断当前需要显示哪个标题栏。
我们要做的具体内容为:
1、固定显示第一个标题栏
2、获得所有标题栏的尺寸和位置
3、在滚动时判断是否有标题栏滚动到了固定的第一个标题栏的位置
4、显示对应的标题栏内容
首先定义一个preferenceKey
struct ListRowPreferenceKey: PreferenceKey {
typealias Value = [ListRowPreferenceData]
static var defaultValue: [ListRowPreferenceData] = []
static func reduce(value: inout [ListRowPreferenceData], nextValue: () -> [ListRowPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
struct ListRowPreferenceData: Equatable {
let rect: CGRect
}
?ListRowPreferenceData中,存放需要记录的子视图的位置数据
我们将列表划分为两种视图组成,标题栏视图和列表内容视图
标题栏视图代码如下
// MARK: 列表标题行
struct MCListTitleRow: View {
var title: String
var body: some View {
GeometryReader { geo in
Text(title).font(.title).bold()
.frame(width: UIScreen.main.bounds.width, height: 60)
.background(Color.black)
.foregroundColor(.white)
.SetPreference(geo: geo, save: true)
}
.frame(width: UIScreen.main.bounds.width, height: 60)
}
}
/// 获得子视图 列表标题栏的大小
/// 将perference定义为modifier模式
struct setPreference: ViewModifier {
var geo: GeometryProxy
var save: Bool
func body(content: Content) -> some View {
if save {
content
.preference(key: ListRowPreferenceKey.self,
value:
[ ListRowPreferenceData(rect: geo.frame(in: .named("VSTACK"))) ])
} else {
content
}
}
}
extension View {
func SetPreference(geo: GeometryProxy, save: Bool) -> some View {
self.modifier(setPreference(geo: geo, save: save))
}
}
在标题栏视图中,使用GeometryReader,配合在主视图中定义的"VSTACK"区域,来确定标题栏的尺寸,并“记录”在ListRowPreferenceKey中。
列表内容视图
struct MultiColumnListData: Identifiable, Hashable, Codable {
var id = UUID()
var column: Int = 1
var widths: [CGFloat] = []
var rowData: [String] = []
}
// MARK: 列表内容行
struct MCListRow: View {
var data: MultiColumnListData = MultiColumnListData()
init(data: MultiColumnListData) {
self.data = data
self.data.column = self.data.rowData.count == 0 ? 1 : self.data.rowData.count
if self.data.widths.count == 0 {
let widthAverage = UIScreen.main.bounds.width / CGFloat(self.data.column)
for _ in 0..<self.data.column {
self.data.widths.append(widthAverage)
}
}
}
var body: some View {
HStack(spacing: 0) {
ForEach(0..<data.column) { i in
Text(data.rowData[i])
.frame(width: data.widths[i], height: 50)
.background(((i % 2) == 0) ? Color.white : Color.gray.opacity(0.3))
}
}
.frame(height: 50)
}
}
根据传入的rowData,来判断分为几列显示,默认按照屏幕宽度平均等分单元格的宽度
主视图关键代码如下:
VStack {
...
ScrollView {}
}
.coordinateSpace(name: "VSTACK")
.onPreferenceChange(ListRowPreferenceKey.self) { preferences in
// 获得第一行标题栏中心点的Y坐标值
let fixedMidY = preferences[0].rect.midY
let sequence = (fixedMidY - offset...fixedMidY + offset)
for i in 1..<preferences.count {
// 判断其他标题栏是否穿过第一行标题栏的位置
if sequence.contains(preferences[i].rect.midY) {
// 更新第一行标题栏显示
if self.direction {
// 向上滚动情况
DispatchQueue.main.async {
self.indexHeader = i
}
} else {
// 向下滚动情况
DispatchQueue.main.async {
self.indexHeader = i - 1 < 0 ? 0 : i - 1
}
}
}
}
}
在onPreferenceChange中,获取子视图我们设置到preference中的尺寸数据,并进行判断
完整代码如下:
MCListRow.swift
//
// MCListRow.swift
// MulitiColumnListTesting
//
// Created by Chr1s on 2021/8/29.
//
import SwiftUI
struct ListRowPreferenceKey: PreferenceKey {
typealias Value = [ListRowPreferenceData]
static var defaultValue: [ListRowPreferenceData] = []
static func reduce(value: inout [ListRowPreferenceData], nextValue: () -> [ListRowPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
struct ListRowPreferenceData: Equatable {
let rect: CGRect
}
// MARK: 列表标题行
struct MCListTitleRow: View {
var title: String
var body: some View {
GeometryReader { geo in
Text(title).font(.title).bold()
.frame(width: UIScreen.main.bounds.width, height: 60)
.background(Color.black)
.foregroundColor(.white)
.SetPreference(geo: geo, save: true)
}
.frame(width: UIScreen.main.bounds.width, height: 60)
}
}
// MARK: 列表内容行
struct MCListRow: View {
var data: MultiColumnListData = MultiColumnListData()
init(data: MultiColumnListData) {
self.data = data
self.data.column = self.data.rowData.count == 0 ? 1 : self.data.rowData.count
if self.data.widths.count == 0 {
let widthAverage = UIScreen.main.bounds.width / CGFloat(self.data.column)
for _ in 0..<self.data.column {
self.data.widths.append(widthAverage)
}
}
}
var body: some View {
HStack(spacing: 0) {
ForEach(0..<data.column) { i in
Text(data.rowData[i])
.frame(width: data.widths[i], height: 50)
.background(((i % 2) == 0) ? Color.white : Color.gray.opacity(0.3))
}
}
.frame(height: 50)
}
}
/// 获得子视图 列表标题栏的大小
/// 将perference定义为modifier模式
struct setPreference: ViewModifier {
var geo: GeometryProxy
var save: Bool
func body(content: Content) -> some View {
if save {
content
.preference(key: ListRowPreferenceKey.self,
value:
[ ListRowPreferenceData(rect: geo.frame(in: .named("VSTACK"))) ])
} else {
content
}
}
}
extension View {
func SetPreference(geo: GeometryProxy, save: Bool) -> some View {
self.modifier(setPreference(geo: geo, save: save))
}
}
struct MCListRow_Previews: PreviewProvider {
static var previews: some View {
Group {
MCListRow(data: MultiColumnListData(rowData: ["1","Lex","24"]))
.previewLayout(.sizeThatFits)
MCListTitleRow(title: "前锋")
.previewLayout(.sizeThatFits)
.preferredColorScheme(.dark)
}
}
}
MCListViewModel.swift
//
// MultiColumnListViewModel.swift
// MulitiColumnListTesting
//
// Created by Chr1s on 2021/8/30.
//
import SwiftUI
struct MultiColumnListData: Identifiable, Hashable, Codable {
var id = UUID()
var column: Int = 1
var widths: [CGFloat] = []
var rowData: [String] = []
}
class MultiColumnListViewModel: ObservableObject {
@Published var listData: [MultiColumnListData] = []
@Published var titleData: [String] = ["前锋", "中场", "后卫"]
@Published var listColumn: Int = 1
init() {
listData.append(MultiColumnListData(rowData: ["7号", "格列兹曼", "30岁"]))
listData.append(MultiColumnListData(rowData: ["9号", "德佩", "24岁"]))
listData.append(MultiColumnListData(rowData: ["11号", "登贝莱", "24岁"]))
listData.append(MultiColumnListData(rowData: ["7号", "格列兹曼", "30岁"]))
listData.append(MultiColumnListData(rowData: ["9号", "德佩", "24岁"]))
listData.append(MultiColumnListData(rowData: ["11号", "登贝莱", "24岁"]))
listData.append(MultiColumnListData(rowData: ["5号", "布斯克茨", "33岁"]))
listData.append(MultiColumnListData(rowData: ["21号", "德容", "22岁"]))
listData.append(MultiColumnListData(rowData: ["20号", "比达尔", "30岁"]))
listData.append(MultiColumnListData(rowData: ["8号", "皮亚尼奇", "25岁"]))
listData.append(MultiColumnListData(rowData: ["5号", "布斯克茨", "33岁"]))
listData.append(MultiColumnListData(rowData: ["21号", "德容", "22岁"]))
listData.append(MultiColumnListData(rowData: ["20号", "比达尔", "30岁"]))
listData.append(MultiColumnListData(rowData: ["8号", "皮亚尼奇", "25岁"]))
listData.append(MultiColumnListData(rowData: ["3号", "皮克", "33岁"]))
listData.append(MultiColumnListData(rowData: ["18号", "阿尔巴", "30岁"]))
listData.append(MultiColumnListData(rowData: ["24号", "埃里克加西", "30岁"]))
listData.append(MultiColumnListData(rowData: ["23号", "乌姆蒂蒂", "25岁"]))
listData.append(MultiColumnListData(rowData: ["3号", "皮克", "33岁"]))
listData.append(MultiColumnListData(rowData: ["18号", "阿尔巴", "30岁"]))
listData.append(MultiColumnListData(rowData: ["24号", "埃里克加西", "30岁"]))
listData.append(MultiColumnListData(rowData: ["23号", "乌姆蒂蒂", "25岁"]))
listData.append(MultiColumnListData(rowData: ["24号", "埃里克加西", "30岁"]))
listData.append(MultiColumnListData(rowData: ["23号", "乌姆蒂蒂", "25岁"]))
listData.append(MultiColumnListData(rowData: ["3号", "皮克", "33岁"]))
listData.append(MultiColumnListData(rowData: ["18号", "阿尔巴", "30岁"]))
listData.append(MultiColumnListData(rowData: ["24号", "埃里克加西", "30岁"]))
listData.append(MultiColumnListData(rowData: ["23号", "乌姆蒂蒂", "25岁"]))
}
}
MCListView.swift
//
// MultiColumnList.swift
// MulitiColumnListTesting
//
// Created by Chr1s on 2021/8/29.
//
import SwiftUI
struct MultiColumnList: View {
@StateObject var vm = MultiColumnListViewModel()
@State var indexHeader: Int = 0
@State var direction: Bool = false
let offset: CGFloat = 5.0
var body: some View {
NavigationView {
VStack(spacing: 0.0) {
/// 固定的第一行列表,显示第一个Title
MCListTitleRow(title: vm.titleData[self.indexHeader])
/// 滚动列表
ScrollView(/*@START_MENU_TOKEN@*/.vertical/*@END_MENU_TOKEN@*/, showsIndicators: false) {
VStack(spacing: 0) {
ForEach(1..<7) { i in
MCListRow(data: vm.listData[i])
Divider()
}
MCListTitleRow(title: vm.titleData[1])
ForEach(7..<15) { i in
MCListRow(data: vm.listData[i])
Divider()
}
MCListTitleRow(title: vm.titleData[2])
ForEach(15..<vm.listData.count) { i in
MCListRow(data: vm.listData[i])
Divider()
}
}
}
.gesture(
DragGesture().onChanged { value in
if value.translation.height > 0 {
self.direction = false
} else {
self.direction = true
}
}
)
}
.navigationTitle("多列列表")
.coordinateSpace(name: "VSTACK")
.onPreferenceChange(ListRowPreferenceKey.self) { preferences in
// 获得第一行标题栏中心点的Y坐标值
let fixedMidY = preferences[0].rect.midY
let sequence = (fixedMidY - offset...fixedMidY + offset)
for i in 1..<preferences.count {
// 判断其他标题栏是否穿过第一行标题栏的位置
if sequence.contains(preferences[i].rect.midY) {
// 更新第一行标题栏显示
if self.direction {
// 向上滚动情况
DispatchQueue.main.async {
self.indexHeader = i
}
} else {
// 向下滚动情况
DispatchQueue.main.async {
self.indexHeader = i - 1 < 0 ? 0 : i - 1
}
}
}
}
}
}
.onAppear {
self.indexHeader = 0
}
}
}
struct MultiColumnList_Previews: PreviewProvider {
static var previews: some View {
MultiColumnList()
}
}
|