SwiftUI 实战二、List 的使用&交互

SwiftUI 实战一、布局总结

SwiftUI 实战二、List 的使用&交互

背景

我们在学习一个东西时,总是很浅的入门。尤其 SwiftUI 非常好上手,它的 List 控件使用时看起来很简单。但是在实际使用场景中,则会面对各种莫名的情况。本篇文章产生于在项目中一个常见又复杂的业务场景下,深度使用了 SwiftUI 的 List 控件,以及一些交互场景,遇到了一些问题也都一一解决。跟着以下例子来看下吧,希望能给你提供一些帮助 ~

如图所示:是一个常见的搜索功能页面(例如微信的联系人列表),我们的重点会放在列表的展示和交互。其包括以下常见功能:

  1. 列表的悬停效果;
  2. 列表的展示:section 间隔问题、分隔线、滚动条
  3. 右侧首字母:触摸时展示气泡、上下移动手势气泡切换、手势停止气泡消失;
  4. section 联动
    1. 右侧首字母点击时,滚动到对应 section 位置;
    2. 列表滚动时,右侧首字母对应选中效果。
  5. 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。网上的一些解决方案有:

  1. 获取底层实现,即这里 List 的底层实现是 UITableView 或 UICollectionView 来控制,上文说过有现成的三方库。但是这样难免未来底层实现可能会再改变,不便维护;
  2. 获取 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

参考了几篇文章,完善了一个封装可供参考。包括:

  1. 自定义侧滑按钮样式
  2. 可选择侧滑整条删除
  3. 列表中多个 cell 同时只展示一个侧滑效果
  4. 解决 一些文章提供的侧滑手势导致整个 List/ScrollView 的垂直滚动不好操作问题

其中第 4 点的解决很值得记录一下,对于再次遇到类似问题有个储备:

如上描述,封装侧滑手势后,整体垂直的滚动不流畅不好用,参考的三篇文章的处理都有这个问题。

  • 解决前,关键手势代码如下:
swift 复制代码
ZStack {}
.simultaneousGesture(DragGesture(minimumDistance: 10, coordinateSpace: .local)

解释下这行代码:

  1. 允许多个手势同时存在(例如 cell 一般也会需要点击手势 .onTapGesture 做一些操作);
  2. 参数 minimumDistance 是指用户触发该手势的最小距离:如果想侧滑好用,那就越小越好。但是越小越容易把垂直手势接收进来(也就是影响到了整个List的垂直滚动);越大很明显就是侧滑越难触发。
  • 解决方案:使用肘子大佬的方案:
swift 复制代码
// 整体
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) // 不需要允许多个手势同时存在,使用0更好触发

// 真正的cell内容
content
	.highPriorityGesture( // 把该手势优先级置高
	TapGesture(count: 1),
	including: .subviews
	)
	.contentShape(Rectangle())
	.onTapGesture( // 实现该手势
	count: 1,
	perform: {
	}
	)

代码说明:

  1. 参数 minimumDistance = 0 更容易触发侧滑手势,优化体验;
  2. 去掉了 .simultaneousGesture,使用普通的 .gesture 修饰符;
  3. 使用 .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)

总结

  1. SwiftUI 代码写法上更简洁、更快
  2. SwiftUI 是逐步发展的,比如它是逐步提供更好用的系统 API,比如底层实现上会逐步使用更好的方式实现。因此在不同系统下,遇到问题时多探索清楚,然后解决问题并注意兼容系统版本
  3. 多实践多落地,在实战中总结学习经验和成长。

参考文章

相关推荐
humiaor18 小时前
Xcode报错:No exact matches in reference to static method ‘buildExpression‘
swiftui·xcode
营赢盈英2 天前
OpenAI GPT-3 API error: “You must provide a model parameter“
chatgpt·gpt-3·openai·swift
一只不会编程的猫3 天前
高德地图绘图,点标记,并计算中心点
开发语言·ios·swift
loongloongz3 天前
Swift语言基础教程、Swift练手小项目、Swift知识点实例化学习
开发语言·学习·swift
2401_858120536 天前
深入理解 Swift 中的隐式解包可选类型(Implicitly Unwrapped Optionals)
开发语言·ios·swift
quaer6 天前
QT chart案例
开发语言·qt·swift
安和昂7 天前
【iOS】UIViewController的生命周期
ios·xcode·swift
00圈圈7 天前
Swift 创建扩展(Extension)
ios·swift·extension
2401_858120537 天前
Swift 中的函数:定义、使用与实践指南
开发语言·ssh·swift
quaer8 天前
VS+QT--实现二进制和十进制的转换(含分数部分)
java·开发语言·python·qt·swift