背景
我们在学习一个东西时,总是很浅的入门。尤其 SwiftUI 非常好上手,它的 List 控件使用时看起来很简单。但是在实际使用场景中,则会面对各种莫名的情况。本篇文章产生于在项目中一个常见又复杂的业务场景下,深度使用了 SwiftUI 的 List 控件,以及一些交互场景,遇到了一些问题也都一一解决。跟着以下例子来看下吧,希望能给你提供一些帮助 ~
如图所示:是一个常见的搜索功能页面(例如微信的联系人列表),我们的重点会放在列表的展示和交互。其包括以下常见功能:
- 列表的悬停效果;
- 列表的展示:section 间隔问题、分隔线、滚动条;
- 右侧首字母:触摸时展示气泡、上下移动手势气泡切换、手势停止气泡消失;
- section 联动 :
- 右侧首字母点击时,滚动到对应 section 位置;
- 列表滚动时,右侧首字母对应选中效果。
- cell 侧滑功能。
以上功能实现代码对比:
方案 | 代码 |
---|---|
UIKit | 760 行 + 侧滑三方库 22 个类 |
SwiftUI | 335 行 + 侧滑自己实现 280 行 |
下面一一来看以上遇到的问题,和对应解决方案。
列表的悬停效果
swift
List {}
.listStyle(.plain)
List 的展示样式同 UITableView.Style 一样,需要悬停的效果我们使用 .plain。
列表的展示:section 间隔问题、分隔线、滚动条
section 间隔
如图所示,黄色部分是我的自定义 SectionView,红色线框范围是上个 section 最后一个 cell 和 section B 之间多余的间距。
原因是同 UITableView,plain 样式下,iOS 15 系统下,section 增加默认高度 sectionHeaderTopPadding。 ① 这里我们可以在视图 init 或 onAppear 给 UITableView 全局设置为 0 来解决(为了避免影响到其他地方,可以在 onDisappear 时重置回去):
swift
// UITableView
if #available(iOS 15.0, *) {
UITableView.appearance().sectionHeaderTopPadding = 0
}
但是,此方法只能解决 iOS 15,iOS 16-17 并未解决。其原因是:
SwiftUI 由于发展不久,所以它的一些控件底层实现是基于 UIKit/AppKit 实现的。 对于 List,在 iOS 16 系统以下的底层实现是 UITableView,之后的系统底层实现则是 UICollectionView(这里可以通过 Xcode 或 Lookin 查看视图图层来证实)。
② 一些文章提供 iOS 16-17 可以通过设置 UICollectionLayoutListConfiguration 来解决 section 间隔问题,如下:
swift
// UICollectionView
if #available(iOS 16.0, *) {
var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
layoutConfig.headerMode = .supplementary
layoutConfig.headerTopPadding = 0 // sectionTopPadding设置为0
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
UICollectionView.appearance().collectionViewLayout = listLayout
}
但是这里有个注意点:同 UITableView.appearance()
只是改变了很简单的属性不同,UICollectionView 这里改变的是 collectionViewLayout 布局配置。它会改变全局所有已生成的 collectionView.collectionViewLayout 为该 layout,从而导致其他已有的 UICollectionView 页面布局错乱。
一些文章提供了 UICollectionView.appearance().collectionViewLayout = UICollectionViewFlowLayout() 为默认值的方式,但其原理同上,会改变所有已生成的 collectionView.collectionViewLayout 为该默认值,还是不对的。解决方式是回到原来页面时,重新设置 原来的 collectionView.collectionViewLayout 为原来的 layout。 但是这样很麻烦,其影响很大,容易忽略导致出错(如果你的场景比较简单可以使用这种方式)。
③ 为了避免以上设置全局的 appearance() 影响,可以通过获取当前 List 底层实现的具体 UITableView/UICollectionView 来设置 。使用 swiftui-introspect 可以帮助我们获取到底层实现。
swift
.introspectTableView { tableView in
// code below
}
.introspectCollectionView { collectionView in
// code below
}
④ 其实 iOS 17 给我们提供了一个 API:
swift
List {...}
.listSectionSpacing(.compact)
通过测试正式版 iOS 17.1.1,在 plain 样式下不起作用,在 grouped 样式下有用(就是这么不给力😠)。
⑤ 以上看下来 iOS 15 可以解决,iOS 16-17 没有得到很方便的解决方式 (毕竟引入 swiftui-introspect 库的成本较大)。如果的 SectionHeader 很高的话可以在布局时把这个间距算进去 (利用 padding(.top, XX))。如果不能,使用" ScrollView + Lazy"吧(替换非常简单)。
swift
ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders], content: {
// 里面和List写法一致
ForEach(0..<10) { section in
Section {
ForEach(0..<10) { ... }
} header: {
...
}
}
})
}
以上是对 List section 间隔的分析,和一些文章解决方案的实施和问题讨论。尝试下来是有些折腾,初学难免的,总归对 List 有了更深的认识了。接下来的问题和解决就很清晰啦~ 😋
分隔线
开篇图上列表的分隔线是我自己画,系统的分隔线一般和整体 UI 都不搭,所以这里我们先把系统的分隔线去掉。 iOS 16 以下系统使用:
swift
// UITableView
UITableView.appearance().separatorStyle = .none
UITableView.appearance().separatorColor = .clear
iOS 16 以上系统可以使用以下修饰符:
swift
List {
ForEach(store.showList, id: \.self) { row in
searchCell(model: row)
.listRowSeparator(.hidden) // 隐藏分隔线
}
}
滚动条
一般来说,为了美观,列表的滚动指示器也需要去除。和以上类似的: iOS 16 以下系统需要使用:
swift
UITableView.appearance().showsVerticalScrollIndicator = false
iOS 16 以上系统可以使用以下修饰符:
swift
List {}
.scrollIndicators(.hidden)
cell 边距
附上一个小知识点,List 的 cell 默认有边距。控制方式为(iOS 13 开始支持):
swift
List {
ForEach(store.showList, id: \.self) { row in
searchCell(model: row)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) // 可自由设置 cell 边距
}
Spacer().frame(height: 100)
}
以上 List 布局上的问题讨论完了,接下来我们看看交互吧:
右侧首字母:触摸时展示气泡、上下移动手势气泡切换、手势停止气泡消失
这里的实现方式很明朗,其实就是需要** touch began、move、end 监听, 但是 SwiftUI 没有相关 API。虽然 SwiftUI 有 DragGesture 可以监听 onChanged、onEnded,但是获取不到 touch began 类似效果,所以这里需要借助 UIKit 的封装**了。
封装代码如下:
swift
import SwiftUI
import UIKit
public struct JKTouchGestureView: UIViewRepresentable {
var tappedCallback: ((UIGestureRecognizer.State, CGPoint) -> Void)
public init(tappedCallback: @escaping (UIGestureRecognizer.State, CGPoint) -> Void) {
self.tappedCallback = tappedCallback
}
public func makeUIView(context: UIViewRepresentableContext<JKTouchGestureView>) -> UIView {
let view = UIView(frame: .zero)
let gesture = SingleTouchDownGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tapped(_:)))
view.addGestureRecognizer(gesture)
return view
}
public class Coordinator: NSObject {
var tappedCallback: ((UIGestureRecognizer.State, CGPoint) -> Void)
init(tappedCallback: @escaping ((UIGestureRecognizer.State, CGPoint) -> Void)) {
self.tappedCallback = tappedCallback
}
@objc func tapped(_ gesture: UITapGestureRecognizer) {
if let view = gesture.view {
let location = gesture.location(in: view)
self.tappedCallback(gesture.state, location)
}
}
}
public func makeCoordinator() -> Coordinator {
Coordinator(tappedCallback: tappedCallback)
}
public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<JKTouchGestureView>) { }
}
private class SingleTouchDownGestureRecognizer: UIGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
self.state = .began
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
self.state = .changed
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
self.state = .ended
}
}
使用时:
swift
...
.overlay( // 监听手势
JKTouchGestureView { state, point in
...
}
)
然后在回调中对 state 不同状态控制:气泡的展示/消失、气泡的位置(气泡箭头对齐字母,这里对齐实现使用 .alignmentGuide,参考之前的文章 SwiftUI 初次实战---布局总结)。
section 联动
如果在 [section 间隔](#section 间隔 "#heading-3") 中选择使用 ScrollView + Lazy 方案,下面的实现对它同样有效,同 List 或 ScrollView 实现的选择没有关系。
右侧首字母点击时,滚动到对应 section 位置
这个很好实现,通过 ScrollViewReader 就可以控制 List 滚动到指定位置。
swift
// 1. 点击首字母时,改变当前首字母选择 model
store.selectedSection = new
// 2. 监听当前选择的 section,ScrollViewReader 滚动到对应为止
ScrollViewReader { scrollViewProxy in
List {
ForEach(store.allList, id: \.self) { section in
Section {
...
} header: {
...
}
.id(section.id) // ① section.id 作为唯一标识,用于 scrollViewProxy 控制滚动
}
...
// ② 监听点击时改变的 当前选中的 section 值
.onChange(of: store.selectedSection) { _ in
if !bubbleHandling { return } // 大致逻辑:不在气泡中,说明是在滚动list,滚动是会改变selectedSection,避免循环影响
withAnimation {
scrollViewProxy.scrollTo(store.selectedSection.id, anchor: .top) // ③ 滚动到当前选中的 section 位置
}
}
}
}
列表滚动时,右侧首字母对应选中效果
这个实现比较困难,因为 iOS 17 之前(iOS 17 有 .scrollPosition,有兴趣可以看之前文章 WWDC23 10159 - Beyond scroll views),SwiftUI 并没有给我们开放监听列表滚动位置的 API。网上的一些解决方案有:
- 获取底层实现,即这里 List 的底层实现是 UITableView 或 UICollectionView 来控制,上文说过有现成的三方库。但是这样难免未来底层实现可能会再改变,不便维护;
- 获取 contentOffset 然后计算滚动到的位置,这个也没有系统 API,网上的实现方式都有可以自己封装一个。但是如果 List 中的 cell 高度如果是布局上动态变化的,就很难计算准确,也很麻烦。
这里我们使用了监听 section 位置的方式来处理:
swift
List {...}
.onAppear {
self.listMinY = geometry.frame(in: .global).minY
}
private func sectionHeader(section: XXX) -> some View {
return ZStack {
GeometryReader { geometry in
Color.yellow
// ① 监听 section 的 frame(这里使用的是 .global,所以下面比较的时候减去整个 list 在 .global 的 minY)
.onChange(of: geometry.frame(in: .global)) { newFrame in
// 大致逻辑:判断没有气泡展示(说明在滚动list)、位置在悬停、且不等于当前正在悬停的model
if !bubbleHandling, abs(newFrame.minY - self.listMinY) < 10, section != store.selectedSection {
store.selectedSection = section // ② 切换section,改变右边index选中的UI(因为4.1已监听改变并滚动到对应位置)
}
}
}
...
}
}
如此,section 的滚动和首字母就完成联动功能了。
cell 侧滑功能
List 中,还有个常见的功能,就是 cell 侧滑。iOS 15 以上有可用的系统 API:
swift
public func swipeActions<T>(edge: HorizontalEdge = .trailing, allowsFullSwipe: Bool = true, @ViewBuilder content: () -> T) -> some View where T : View
参考了几篇文章,完善了一个封装可供参考。包括:
- 自定义侧滑按钮样式
- 可选择侧滑整条删除
- 列表中多个 cell 同时只展示一个侧滑效果
- 解决 一些文章提供的侧滑手势导致整个 List/ScrollView 的垂直滚动不好操作问题
其中第 4 点的解决很值得记录一下,对于再次遇到类似问题有个储备:
如上描述,封装侧滑手势后,整体垂直的滚动不流畅不好用,参考的三篇文章的处理都有这个问题。
- 解决前,关键手势代码如下:
swift
ZStack {}
.simultaneousGesture(DragGesture(minimumDistance: 10, coordinateSpace: .local)
解释下这行代码:
- 允许多个手势同时存在(例如 cell 一般也会需要点击手势 .onTapGesture 做一些操作);
- 参数 minimumDistance 是指用户触发该手势的最小距离:如果想侧滑好用,那就越小越好。但是越小越容易把垂直手势接收进来(也就是影响到了整个List的垂直滚动);越大很明显就是侧滑越难触发。
- 解决方案:使用肘子大佬的方案:
swift
// 整体
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) // 不需要允许多个手势同时存在,使用0更好触发
// 真正的cell内容
content
.highPriorityGesture( // 把该手势优先级置高
TapGesture(count: 1),
including: .subviews
)
.contentShape(Rectangle())
.onTapGesture( // 实现该手势
count: 1,
perform: {
}
)
代码说明:
- 参数 minimumDistance = 0 更容易触发侧滑手势,优化体验;
- 去掉了 .simultaneousGesture,使用普通的 .gesture 修饰符;
- 使用 .highPriorityGesture 修饰符把 TapGesture(count: 1) 置为高优先级,并实现该手势。
原因分析:
- .simultaneousGesture 允许多个手势同时存在,但是会导致 List 的垂直手势 和 cell DragGesture 手势冲突,并有时候优先被 cell DragGesture 接收,导致 List 的整体垂直滚动交互不易操作;
- List 的整体滑动手势控制不了,又没有类似 lowPriorityGesture 修饰符能把 cell DragGesture 的优先级降低;
- 所以通过使用 .highPriorityGesture,可以把某个手势设置更高的优先级,确保在冲突时它能够被优先响应。虽然它会影响到 content 视图中的手势,但它不会直接影响到 List 的垂直滚动手势。这样做的结果是,可以在 content 视图中实现自定义的手势逻辑即 cell DragGesture,而不会干扰 List 的默认垂直滚动行为。
其实这个分析也不是很透彻,这个解决方案确实很神奇,很难考虑到这个思路,记录下来之后再遇到手势冲突或许可以参考。
完整代码如下:
swift
import SwiftUI
// MARK: - JKSwipeActionButton
public struct JKSwipeActionButton: Identifiable {
public let id = UUID()
let buttonView: () -> AnyView
let action: () -> Void
let width: CGFloat
public init(buttonView: @escaping () -> AnyView,
action: @escaping () -> Void,
width: CGFloat = 60) {
self.buttonView = buttonView
self.action = action
self.width = width
}
}
public extension View {
func eraseToAnyView() -> AnyView {
return AnyView(self)
}
}
// MARK: - JKSwipeActionView
// Adds custom swipe actions to a given view
public struct JKSwipeActionView: ViewModifier {
var id: String
// Buttons at the leading (left-hand) side
let leading: [JKSwipeActionButton]
// Can you full swipe the leading side
let allowsFullSwipeLeading: Bool
// Buttons at the trailing (right-hand) side
let trailing: [JKSwipeActionButton]
// Can you full swipe the trailing side
let allowsFullSwipeTrailing: Bool
// To list at the same time only one or none in the side slide
@Binding var currentUserInteractionID: String?
private let totalLeadingWidth: CGFloat!
private let totalTrailingWidth: CGFloat!
// How much does the user have to swipe at least to reveal buttons on either side, init calculated
private var minSwipeableWidth: CGFloat = 0
@State private var offset: CGFloat = 0 {
didSet {
currentUserInteractionID = offset == 0 ? nil : self.id
}
}
@State private var prevOffset: CGFloat = 0
// Make sure the content height is truly rendered
@State private var contentViewHeight: CGFloat = 0
init(id: String,
leading: [JKSwipeActionButton] = [],
allowsFullSwipeLeading: Bool = false,
trailing: [JKSwipeActionButton] = [],
allowsFullSwipeTrailing: Bool = false,
currentUserInteractionID: Binding<String?>) {
self.id = id
self.leading = leading
self.allowsFullSwipeLeading = allowsFullSwipeLeading && !leading.isEmpty
self.trailing = trailing
self.allowsFullSwipeTrailing = allowsFullSwipeTrailing && !trailing.isEmpty
self._currentUserInteractionID = currentUserInteractionID
totalLeadingWidth = leading.reduce(0, { partialResult, button in
partialResult + button.width
})
totalTrailingWidth = trailing.reduce(0, { partialResult, button in
partialResult + button.width
})
if let last = trailing.last {
minSwipeableWidth = last.width * 0.8
} else if let first = leading.first {
minSwipeableWidth = first.width * 0.8
}
}
// swiftlint:disable function_body_length
public func body(content: Content) -> some View {
// Use a GeometryReader to get the size of the view on which we're adding
// the custom swipe actions.
GeometryReader { geo in
// Place leading buttons, the wrapped content and trailing buttons
// in an HStack with no spacing.
HStack(spacing: 0) {
// If any swiping on the left-hand side has occurred, reveal
// leading buttons. This also resolves button flickering.
if offset > 0 {
// If the user has swiped enough for it to qualify as a full swipe,
// render just the first button across the entire swipe length.
if fullSwipeEnabled(edge: .leading, width: geo.size.width) {
button(for: leading.first)
.frame(width: offset, height: geo.size.height)
} else {
// If we aren't in a full swipe, render all buttons with widths
// proportional to the swipe length.
ForEach(leading) { actionView in
button(for: actionView)
.frame(width: individualButtonWidth(edge: .leading),
height: geo.size.height)
}
}
}
// This is the list row itself
content
// Add horizontal padding as we removed it to allow the
// swipe buttons to occupy full row height.
.frame(width: geo.size.width, alignment: .leading)
.offset(x: (offset > 0) ? 0 : offset)
.background(
GeometryReader(content: { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
})
)
.onPreferenceChange(SizePreferenceKey.self, perform: { value in
contentViewHeight = value.height
})
.highPriorityGesture(
TapGesture(count: 1),
including: .subviews
)
.contentShape(Rectangle())
// fix list scroll unEnabled
.onTapGesture(
count: 1,
perform: {
}
)
// If any swiping on the right-hand side has occurred, reveal
// trailing buttons. This also resolves button flickering.
if offset < 0 {
Group {
// If the user has swiped enough for it to qualify as a full swipe,
// render just the last button across the entire swipe length.
if fullSwipeEnabled(edge: .trailing, width: geo.size.width) {
button(for: trailing.last)
.frame(width: -offset, height: geo.size.height)
} else {
// If we aren't in a full swipe, render all buttons with widths
// proportional to the swipe length.
ForEach(trailing) { actionView in
button(for: actionView)
.frame(width: individualButtonWidth(edge: .trailing),
height: geo.size.height)
}
}
}
// The leading buttons need to move to the left as the swipe progresses.
.offset(x: offset)
}
}
// animate the view as `offset` changes
.animation(.spring(), value: offset)
// allows the DragGesture to work even if there are now interactable
// views in the row
.contentShape(Rectangle())
// The DragGesture distates the swipe. The minimumDistance is there to
// prevent the gesture from interfering with List vertical scrolling.
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { gesture in
// Compute the total swipe based on the gesture values.
var total = gesture.translation.width + prevOffset
if !allowsFullSwipeLeading {
total = min(total, totalLeadingWidth)
}
if !allowsFullSwipeTrailing {
total = max(total, -totalTrailingWidth)
}
offset = total
}
.onEnded { _ in
// Adjust the offset based on if the user has swiped enough to reveal
// all the buttons or not. Also handles full swipe logic.
if offset > minSwipeableWidth && !leading.isEmpty {
if !checkAndHandleFullSwipe(for: leading, edge: .leading, width: geo.size.width) {
offset = totalLeadingWidth
}
} else if offset < -minSwipeableWidth && !trailing.isEmpty {
if !checkAndHandleFullSwipe(for: trailing, edge: .trailing, width: -geo.size.width) {
offset = -totalTrailingWidth
}
} else {
offset = 0
}
prevOffset = offset
})
}
.frame(height: contentViewHeight)
.onChange(of: currentUserInteractionID) { (_) in
if (currentUserInteractionID == nil && (offset == totalLeadingWidth || offset == -totalTrailingWidth))
|| (currentUserInteractionID != nil && currentUserInteractionID != self.id && offset != 0) {
self.offset = 0
prevOffset = 0
}
}
// Remove internal row padding to allow the buttons to occupy full row height
.listRowInsets(EdgeInsets())
}
// Checks if full swipe is supported and currently active for the given edge.
// The current threshold is at half of the row width.
private func fullSwipeEnabled(edge: Edge, width: CGFloat) -> Bool {
let threshold = abs(width) / 2
switch edge {
case .leading:
return allowsFullSwipeLeading && offset > threshold
case .trailing:
return allowsFullSwipeTrailing && -offset > threshold
}
}
// Creates the view for each JKSwipeActionButton. Also assigns it
// a tap gesture to handle the click and reset the offset.
private func button(for button: JKSwipeActionButton?) -> some View {
button?.buttonView()
.onTapGesture {
button?.action()
offset = 0
prevOffset = 0
}
}
// Calculates width for each button, proportional to the swipe.
private func individualButtonWidth(edge: Edge) -> CGFloat {
switch edge {
case .leading:
return (offset > 0) ? (offset / CGFloat(leading.count)) : 0
case .trailing:
return (offset < 0) ? (abs(offset) / CGFloat(trailing.count)) : 0
}
}
// Checks if the view is in full swipe. If so, trigger the action on the
// correct button (left- or right-most one), make it full the entire row
// and schedule everything to be reset after a while.
private func checkAndHandleFullSwipe(for collection: [JKSwipeActionButton],
edge: Edge,
width: CGFloat) -> Bool {
if fullSwipeEnabled(edge: edge, width: width) {
offset = width * CGFloat(collection.count) * 1.2
((edge == .leading) ? collection.first : collection.last)?.action()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
offset = 0
prevOffset = 0
}
return true
} else {
return false
}
}
private enum Edge {
case leading, trailing
}
}
private struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
// MARK: - Extensiong
extension View {
public func jk_swipeActions(id: String = UUID().uuidString,
leading: [JKSwipeActionButton] = [],
allowsFullSwipeLeading: Bool = false,
trailing: [JKSwipeActionButton] = [],
allowsFullSwipeTrailing: Bool = false,
currentUserInteractionID: Binding<String?>) -> some View {
modifier(JKSwipeActionView(id: id,
leading: leading,
allowsFullSwipeLeading: allowsFullSwipeLeading,
trailing: trailing,
allowsFullSwipeTrailing: allowsFullSwipeTrailing,
currentUserInteractionID: currentUserInteractionID))
}
}
业务场景使用:
swift
/// 当前交互的cellId
@State var currentUserInteractionID: String?
// cell
PESportSearchCell(model: model) {
currentUserInteractionID = nil // 比如点击整个cell或其他操作时:取消当前侧滑状态
}
.jk_swipeActions(id: model.id.uuidString, trailing: [
JKSwipeActionButton(buttonView: { // 自定义侧滑 UI
Image("sport_search_delete")
.padding(.trailing, 20)
.eraseToAnyView()
}, action: {
// ...
}, width: 44)
], currentUserInteractionID: $currentUserInteractionID)
总结
- SwiftUI 代码写法上更简洁、更快;
- SwiftUI 是逐步发展的,比如它是逐步提供更好用的系统 API,比如底层实现上会逐步使用更好的方式实现。因此在不同系统下,遇到问题时多探索清楚,然后解决问题并注意兼容系统版本;
- 多实践多落地,在实战中总结学习经验和成长。
参考文章
- Reducing space between sections for grouped List in SwiftUI?
- Remove list UICollectionView cell separators
- iOS 16 not responding to changes in section spacing
- How to remove space before SwiftUI List Sections?
- SwiftUI List Custom Row Swipe Actions (All Versions)
- SwiftUI: How to make custom Swipe-able Cell
- SwipeCellSUI_Example
- SwipeCell