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. 多实践多落地,在实战中总结学习经验和成长。

参考文章

相关推荐
报错小能手3 天前
ios开发方向——swift并发进阶核心 Task、Actor、await 详解
开发语言·学习·ios·swift
用户79457223954134 天前
【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身
objective-c·swift
报错小能手4 天前
SwiftUI 框架 认识 SwiftUI 视图结构 + 布局
ui·ios·swift
东坡肘子4 天前
被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131
人工智能·swiftui·swift
报错小能手4 天前
ios开发方向——swift错误处理:do/try/catch、Result、throws
开发语言·学习·ios·swift
小夏子_riotous4 天前
openstack的使用——5. Swift服务的基本使用
linux·运维·开发语言·分布式·云计算·openstack·swift
mCell5 天前
MacOS 下实现 AI 操控电脑(Computer Use)的思考
macos·agent·swift
用户79457223954135 天前
【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线
swiftui·swift
chaoguo12345 天前
Any metadata 的内存布局
swift·metadata·value witness table
tangweiguo030519876 天前
SwiftUI布局完全指南:从入门到精通
ios·swift