背景
咱们在学习一个东西时,总是很浅的入门。尤其 SwiftUI 十分好上手,它的 List 控件运用时看起来很简单。可是在实际运用场景中,则会面临各种莫名的状况。本篇文章产生于在项目中一个常见又复杂的业务场景下,深度运用了 SwiftUI 的 List 控件,以及一些交互场景,遇到了一些问题也都逐个处理。跟着以下例子来看下吧,希望能给你供给一些帮助 ~
如图所示:是一个常见的搜索功用页面(例如微信的联系人列表),咱们的重点会放在列表的展现和交互。其包含以下常见功用:
- 列表的悬停作用;
- 列表的展现:section 距离问题、分隔线、翻滚条;
- 右侧首字母:接触时展现气泡、上下移动手势气泡切换、手势中止气泡消失;
-
section 联动:
- 右侧首字母点击时,翻滚到对应 section 方位;
- 列表翻滚时,右侧首字母对应选中作用。
- cell 侧滑功用。
以上功用完结代码对比:
计划 | 代码 |
---|---|
UIKit | 760 行 + 侧滑三方库 22 个类 |
SwiftUI | 335 行 + 侧滑自己完结 280 行 |
下面逐个来看以上遇到的问题,和对应处理计划。
列表的悬停作用
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 时重置回去):
// 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 距离问题,如下:
// 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 可以帮助咱们获取到底层完结。
.introspectTableView { tableView in
// code below
}
.introspectCollectionView { collectionView in
// code below
}
④ 其实 iOS 17 给咱们供给了一个 API:
List {...}
.listSectionSpacing(.compact)
通过测验正式版 iOS 17.1.1,在 plain 款式下不起作用,在 grouped 款式下有用(便是这么不给力)。
⑤ 以上看下来 iOS 15 可以处理,iOS 16-17 没有得到很方便的处理方法(究竟引进 swiftui-introspect 库的成本较大)。假如的 SectionHeader 很高的话可以在布局时把这个距离算进去(运用 padding(.top, XX))。假如不能,运用“ ScrollView + Lazy”吧(替换十分简单)。
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 以下体系运用:
// UITableView
UITableView.appearance().separatorStyle = .none
UITableView.appearance().separatorColor = .clear
iOS 16 以上体系可以运用以下修饰符:
List {
ForEach(store.showList, id: .self) { row in
searchCell(model: row)
.listRowSeparator(.hidden) // 躲藏分隔线
}
}
翻滚条
一般来说,为了漂亮,列表的翻滚指示器也需求去除。和以上相似的: iOS 16 以下体系需求运用:
UITableView.appearance().showsVerticalScrollIndicator = false
iOS 16 以上体系可以运用以下修饰符:
List {}
.scrollIndicators(.hidden)
cell 边距
附上一个小知识点,List 的 cell 默许有边距。操控方法为(iOS 13 开始支持):
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 的封装**了。
封装代码如下:
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
}
}
运用时:
...
.overlay( // 监听手势
JKTouchGestureView { state, point in
...
}
)
然后在回调中对 state 不同状况操控:气泡的展现/消失、气泡的方位(气泡箭头对齐字母,这儿对齐完结运用 .alignmentGuide,参阅之前的文章 SwiftUI 初次实战—布局总结)。
section 联动
假如在 section 距离 中挑选运用 ScrollView + Lazy 计划,下面的完结对它相同有用,同 List 或 ScrollView 完结的挑选没有关系。
右侧首字母点击时,翻滚到对应 section 方位
这个很好完结,通过 ScrollViewReader 就可以操控 List 翻滚到指定方位。
// 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 方位的方法来处理:
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:
public func swipeActions<T>(edge: HorizontalEdge = .trailing, allowsFullSwipe: Bool = true, @ViewBuilder content: () -> T) -> some View where T : View
参阅了几篇文章,完善了一个封装可供参阅。包含:
- 自定义侧滑按钮款式
- 可挑选侧滑整条删去
- 列表中多个 cell 一起只展现一个侧滑作用
- 处理一些文章供给的侧滑手势导致整个 List/ScrollView 的笔直翻滚欠好操作问题
其中第 4 点的处理很值得记载一下,关于再次遇到相似问题有个储备:
如上描述,封装侧滑手势后,整体笔直的翻滚不流畅欠好用,参阅的三篇文章的处理都有这个问题。
- 处理前,要害手势代码如下:
ZStack {}
.simultaneousGesture(DragGesture(minimumDistance: 10, coordinateSpace: .local)
解说下这行代码:
- 答应多个手势一起存在(例如 cell 一般也会需求点击手势 .onTapGesture 做一些操作);
- 参数 minimumDistance 是指用户触发该手势的最小距离:假如想侧滑好用,那就越小越好。可是越小越简单把笔直手势接纳进来(也便是影响到了整个List的笔直翻滚);越大很明显便是侧滑越难触发。
- 处理计划:运用肘子大佬的计划:
// 整体
.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 的默许笔直翻滚行为。
其实这个剖析也不是很透彻,这个处理计划的确很神奇,很难考虑到这个思路,记载下来之后再遇到手势抵触或许可以参阅。
完整代码如下:
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))
}
}
业务场景运用:
/// 当时交互的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