小册子之 List、Lazy 容器、ScrollView、Grid 和 Table 数据集合视图

以下内容已整理到小册子中,小册子代码在 Github 上,可以在 macOS 应用商店安装"戴铭的开发小册子"应用查看。

ForEach

使用

在 SwiftUI 中,ForEach 是一个结构体,它可以创建一组视图,每个视图都有一个与数据集中的元素相对应的唯一标识符。这对于在列表或其他集合视图中显示数据非常有用。

以下视图集会用到 ForEach:

  • List
  • ScrollView
  • LazyVStack / LazyHStack
  • Picker
  • Grids (LazyVGrid / LazyHGrid)

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,你可以这样做:

swift 复制代码
struct ContentView: View {
    var bookmarks: [BookmarkModel]

    var body: some View {
        List {
            ForEach(bookmarks) { bookmark in
                Text(bookmark.name)
            }
        }
    }
}

ForEach 遍历 bookmarks 数组,并为每个 BookmarkModel 对象创建一个 Text 视图。bookmark 参数是当前遍历的 BookmarkModel 对象。

BookmarkModel 必须遵循 Identifiable 协议,这样 SwiftUI 才能知道如何唯一地标识每个视图。在你的代码中,BookmarkModel 已经有一个 id 属性,所以你只需要让 BookmarkModel 遵循 Identifiable 协议即可:

swift 复制代码
final class BookmarkModel: Identifiable {
    // your code here
}

使用索引范围进行编号

你可以使用 ForEach 结构体的另一个版本,它接受一个范围作为其数据源。这个范围可以是一个索引范围,这样你就可以为每个项目编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

swift 复制代码
struct ContentView: View {
    var bookmarks: [BookmarkModel]

    var body: some View {
        List {
            ForEach(bookmarks.indices, id: \.self) { index in
                Text("\(index + 1). \(bookmarks[index].name)")
            }
        }
    }
}

在这个例子中,ForEach 遍历 bookmarks 数组的索引,并为每个 BookmarkModel 对象创建一个 Text 视图。index 参数是当前遍历的索引。我们使用 \(index + 1). \(bookmarks[index].name) 来创建一个带有编号的文本视图。请注意,我们使用 index + 1 而不是 index,因为数组的索引是从 0 开始的,但我们通常希望编号是从 1 开始的。

使用 enumerated 编号

enumerated()

以下是一个例子:

swift 复制代码
struct ContentView: View {
    var bookmarks: [BookmarkModel]

    var body: some View {
        List {
            ForEach(Array(bookmarks.enumerated()), id: \.element.id) { index, bookmark in
                Text("\(index). \(bookmark.name)")
            }
        }
    }
}

我们使用 Array(bookmarks.enumerated()) 来创建一个元组数组,每个元组包含一个索引和一个 BookmarkModel 对象。然后,我们使用 ForEach 遍历这个元组数组,并为每个元组创建一个 Text 视图。index 参数是当前遍历的索引,bookmark 参数是当前遍历的 BookmarkModel 对象。

使用 zip 编号

zip(_:_:) 函数可以将两个序列合并为一个元组序列。你可以使用这个函数和 ForEach 结构体来为数组中的每个元素添加一个编号。

例如,如果你有一个 BookmarkModel 的数组,并且你想为每个书签创建一个文本视图,并在前面添加一个编号,你可以这样做:

swift 复制代码
struct ContentView: View {
    var bookmarks: [BookmarkModel]

    var body: some View {
        List {
            ForEach(Array(zip(1..., bookmarks)), id: \.1.id) { index, bookmark in
                Text("\(index). \(bookmark.name)")
            }
        }
    }
}

写出扩展,方便调用

swift 复制代码
@dynamicMemberLookup
struct Numbered<Element> {
    var number: Int
    var element: Element
    
    subscript<T>(dynamicMember keyPath: WritableKeyPath<Element, T>) -> T {
        get { element[keyPath: keyPath] }
        set { element[keyPath: keyPath] = newValue }
    }
}

extension Sequence {
    func numbered(startingAt start: Int = 1) -> [Numbered<Element>] {
        zip(start..., self)
            .map { Numbered(number: $0.0, element: $0.1) }
    }
}

extension Numbered: Identifiable where Element: Identifiable {
    var id: Element.ID { element.id }
}

使用:

swift 复制代码
ForEach(bookmark.numbered()) { numberedBookmark in
    Text("\(numberedBookmark.number). \(numberedBookmark.name)")
}

Scroll视图

ScrollView

新增 modifier

swift 复制代码
ScrollView {
    ForEach(0..<300) { i in
        Text("\(i)")
            .id(i)
    }
}
.scrollDisabled(false) // 设置是否可滚动
.scrollDismissesKeyboard(.interactively) // 关闭键盘
.scrollIndicators(.visible) // 设置滚动指示器是否可见

ScrollViewReader

ScrollView 使用 scrollTo 可以直接滚动到指定的位置。ScrollView 还可以透出偏移量,利用偏移量可以定义自己的动态视图,比如向下向上滚动视图时有不同效果,到顶部显示标题视图等。

示例代码如下:

swift 复制代码
struct PlayScrollView: View {
    @State private var scrollOffset: CGFloat = .zero
    
    var infoView: some View {
        GeometryReader { g in
            Text("移动了 \(Double(scrollOffset).formatted(.number.precision(.fractionLength(1)).rounded()))")
                .padding()
        }
    }
    
    var body: some View {
        // 标准用法
        ScrollViewReader { s in
            ScrollView {
                ForEach(0..<300) { i in
                    Text("\(i)")
                        .id(i)
                }
            }
            Button("跳到150") {
                withAnimation {
                    s.scrollTo(150, anchor: .top)
                }
            } // end Button
        } // end ScrollViewReader
        
        // 自定义的 ScrollView 透出 offset 供使用
        ZStack {
            PCScrollView {
                ForEach(0..<100) { i in
                    Text("\(i)")
                }
            } whenMoved: { d in
                scrollOffset = d
            }
            infoView
            
        } // end ZStack
    } // end body
}

// MARK: - 自定义 ScrollView
struct PCScrollView<C: View>: View {
    let c: () -> C
    let whenMoved: (CGFloat) -> Void
    
    init(@ViewBuilder c: @escaping () -> C, whenMoved: @escaping (CGFloat) -> Void) {
        self.c = c
        self.whenMoved = whenMoved
    }
    
    var offsetReader: some View {
        GeometryReader { g in
            Color.clear
                .preference(key: OffsetPreferenceKey.self, value: g.frame(in: .named("frameLayer")).minY)
        }
        .frame(height:0)
    }
    
    var body: some View {
        ScrollView {
            offsetReader
            c()
                .padding(.top, -8)
        }
        .coordinateSpace(name: "frameLayer")
        .onPreferenceChange(OffsetPreferenceKey.self, perform: whenMoved)
    } // end body
}

private struct OffsetPreferenceKey: PreferenceKey {
  static var defaultValue: CGFloat = .zero
  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

固定到滚动视图的顶部

LazyVStack 有个参数 pinnedViews 可以用于固定滚动视图的顶部。

swift 复制代码
ScrollView {
    LazyVStack(alignment: .leading, spacing: 10, pinnedViews: .sectionHeaders) {
        Section {
            ForEach(books) { book in
                BookRowView(book: book)
            }
        } header: {
            HeaderView(title: "小说")
        }
        ....
    }
}

滚动到特定的位置

scrollPostion 版本

scrollPositon(id:) 比 ScrollViewReader 简单,但是只适用于 ScrollView。数据源遵循 Identifiable,不用显式使用 id 修饰符

swift 复制代码
struct ContentView: View {
    @State private var id: Int?

    var body: some View {
        VStack {
            Button("Scroll to Bookmark 3") {
                withAnimation {
                    id = 3
                }
            }
            Button("Scroll to Bookmark 13") {
                withAnimation {
                    id = 13
                }
            }
            ScrollView {
                ScrollViewReader { scrollView in
                    LazyVStack {
                        ForEach(Bookmark.simpleData()) { bookmark in
                            Text("\(bookmark.index)")
                                .id(bookmark.index)
                        }
                        
                    }
                }
            }
            .scrollPosition(id: $id)
            .scrollTargetLayout()
        }
    }
    
    struct Bookmark: Identifiable,Hashable {
        let id = UUID()
        let index: Int
        
        static func simpleData() -> [Bookmark] {
            var re = [Bookmark]()
            for i in 0...100 {
                re.append(Bookmark(index: i))
            }
            return re
        }
    }
}

scrollTargetLayout 可以获得当前滚动位置。锚点不可配,默认是 center。

ScrollViewReader 版本

ScrollViewReader 这个版本可以适用于 List,也可以配置锚点

你可以使用 ScrollViewReaderscrollTo(_:anchor:) 方法来滚动到特定的元素。以下是一个例子:

swift 复制代码
struct ContentView: View {
    var bookmarks: [Int] = Array(1...100)
    @State private var selectedBookmarkId: Int?

    var body: some View {
        VStack {
            Button("Scroll to Bookmark 3") {
                selectedBookmarkId = 3
            }
            Button("Scroll to Bookmark 13") {
                selectedBookmarkId = 13
            }
            ScrollView {
                ScrollViewReader { scrollView in
                    LazyVStack {
                        ForEach(bookmarks.indices, id: \.self) { index in
                            Text("\(bookmarks[index])")
                                .id(index)
                        }
                        .onChange(of: selectedBookmarkId) { oldValue, newValue in
                            if let newValue = newValue {
                                withAnimation {
                                    scrollView.scrollTo(newValue, anchor: .top)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

在这个例子中,我们首先创建了一个 Button,当点击这个按钮时,selectedBookmarkId 的值会被设置为 3。然后,我们创建了一个 ScrollView,并在 ScrollView 中添加了一个 ScrollViewReader。我们在 ScrollViewReader 中添加了一个 LazyVStack,并使用 ForEach 遍历 bookmarks 数组的索引,为每个索引创建一个 Text 视图。我们使用 id(_:) 方法为每个 Text 视图设置了一个唯一的 ID。

我们使用 onChange(of:perform:) 方法来监听 selectedBookmarkId 的变化。当 selectedBookmarkId 的值改变时,我们会调用 scrollTo(_:anchor:) 方法来滚动到特定的元素。anchor: .top 参数表示我们希望滚动到的元素位于滚动视图的顶部。

scrollTargetBehavior分页滚动

按可视尺寸分页

.scrollTargetBehavior(.paging) 可以让 ScrollView 滚动,滚动一页的范围是 ScrollView 的可视尺寸。

swift 复制代码
struct ContentView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0...20, id: \.self) { i in
                    colorView()
                        .frame(width: 300, height: 200)
                }
            }
        }
        .scrollTargetBehavior(.paging)
    }
    
    @ViewBuilder
    func colorView() -> some View {
        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()
    }
}

按容器元素对齐分页

使用 .scrollTargetBehavior(.viewAligned) 配合 scrollTargetLayout。示例代码如下:

swift 复制代码
struct ContentView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0...20, id: \.self) { i in
                    colorView()
                        .frame(width: 300, height: 200)
                }
            }
            .scrollTargetLayout(isEnabled: true)
        }
        .scrollTargetBehavior(.viewAligned)
    }
    
    @ViewBuilder
    func colorView() -> some View {
        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()
    }
}

scrollTransition视觉效果

iOS 17 新推出 .scrollTransition,用于处理滚动时的动画。

.transition 用于视图插入和移除视图树时的动画。

.scrollTransition 会和滚动联合起来进行平滑的过渡动画处理。.scrollTransition 可以修改很多属性,比如大小,可见性还有旋转等。

.scrollTransition 可以针对不同阶段进行处理,目前有三个阶段:

  • topLeading: 视图进入 ScrollView 可见区域
  • identity: 在可见区域中
  • bottomTrailing: 视图离开 ScrollView 可见区域
swift 复制代码
struct ContentView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0...20, id: \.self) { i in
                    colorView()
                        .frame(width: 300, height: 200)
                        .scrollTransition { content, phase in 
                            content
                                .scaleEffect(phase.isIdentity ? 1 : 0.4)
                        }
                }
            }
        }
    }
    
    @ViewBuilder
    func colorView() -> some View {
        [Color.red, Color.yellow, Color.blue, Color.mint, Color.indigo, Color.green].randomElement()
    }
}

使用阶段的值

swift 复制代码
.scrollTransition(.animated(.bouncy)) { content, phase in
    content
        .scaleEffect(phase.isIdentity ? 1 : phase.value)
}

不同阶段的产生效果设置

swift 复制代码
.scrollTransition(
    topLeading: .animated,
    bottomTrailing: .interactive
) { content, phase in
    content.rotationEffect(.radians(phase.value))
}

.rotation3DEffect 也是支持的。

swift 复制代码
.scrollTransition(.interactive) { content, phase in
    content
        .rotation3DEffect(
            Angle.degrees(phase.isIdentity ? 0: 120),
            axis: (x: 0.9, y: 0.0, z: 0.1))
        .offset(x: phase.value * -300)
}

ScrollView-参考资料

文档

WWDC

23

List列表

List

List 除了能够展示数据外,还有下拉刷新、过滤搜索和侧滑 Swipe 动作提供更多 Cell 操作的能力。

通过 List 的可选子项参数提供数据模型的关键路径来制定子项路劲,还可以实现大纲视图,使用 DisclosureGroup 和 OutlineGroup 可以进一步定制大纲视图。

使用 .listRowSeparator(.hidden, edges: .all) 可以隐藏分割线。

下面是 List 使用,包括了 DisclosureGroup 和 OutlineGroup 的演示代码:

swift 复制代码
struct PlayListView: View {
    @StateObject var l: PLVM = PLVM()
    @State private var s: String = ""
    
    var outlineModel = [
        POutlineModel(title: "文件夹一", iconName: "folder.fill", children: [
            POutlineModel(title: "个人", iconName: "person.crop.circle.fill"),
            POutlineModel(title: "群组", iconName: "person.2.circle.fill"),
            POutlineModel(title: "加好友", iconName: "person.badge.plus")
        ]),
        POutlineModel(title: "文件夹二", iconName: "folder.fill", children: [
            POutlineModel(title: "晴天", iconName: "sun.max.fill"),
            POutlineModel(title: "夜间", iconName: "moon.fill"),
            POutlineModel(title: "雨天", iconName: "cloud.rain.fill", children: [
                POutlineModel(title: "雷加雨", iconName: "cloud.bolt.rain.fill"),
                POutlineModel(title: "太阳雨", iconName: "cloud.sun.rain.fill")
            ])
        ]),
        POutlineModel(title: "文件夹三", iconName: "folder.fill", children: [
            POutlineModel(title: "电话", iconName: "phone"),
            POutlineModel(title: "拍照", iconName: "camera.circle.fill"),
            POutlineModel(title: "提醒", iconName: "bell")
        ])
    ]
    
    var body: some View {
        HStack {
            // List 通过$语法可以将集合的元素转换成可绑定的值
            List {
                ForEach($l.ls) { $d in
                    PRowView(s: d.s, i: d.i)
                        .listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
                        .listRowBackground(Color.black.opacity(0.2))
                }
            }
            .refreshable {
                // 下拉刷新
            }
            .searchable(text: $s) // 搜索
            .onChange(of: s) { newValue in
                print("搜索关键字:\(s)")
            }
            
            Divider()
            
            // 自定义 List
            VStack {
                PCustomListView($l.ls) { $d in
                    PRowView(s: d.s, i: d.i)
                }
                // 添加数据
                Button {
                    l.ls.append(PLModel(s: "More", i: 0))
                } label: {
                    Text("添加")
                }
            }
            .padding()
            
            Divider()
            
            // 使用大纲
            List(outlineModel, children: \.children) { i in
                Label(i.title, systemImage: i.iconName)
            }
            
            Divider()
            
            // 自定义大纲视图
            VStack {
                Text("可点击标题展开")
                    .font(.headline)
                PCOutlineListView(d: outlineModel, c: \.children) { i in
                    Label(i.title, systemImage: i.iconName)
                }
            }
            .padding()
            
            Divider()
            
            // 使用 OutlineGroup 实现大纲视图
            VStack {
                Text("OutlineGroup 实现大纲")
                
                OutlineGroup(outlineModel, children: \.children) { i in
                    Label(i.title, systemImage: i.iconName)
                }
                
                // OutlineGroup 和 List 结合
                Text("OutlineGroup 和 List 结合")
                List {
                    ForEach(outlineModel) { s in
                        Section {
                            OutlineGroup(s.children ?? [], children: \.children) { i in
                                Label(i.title, systemImage: i.iconName)
                            }
                        } header: {
                            Label(s.title, systemImage: s.iconName)
                        }

                    } // end ForEach
                } // end List
            } // end VStack
        } // end HStack
    } // end body
}

// MARK: - 自定义大纲视图
struct PCOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {
    private let v: PCOutlineView<D, Content>
    
    init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {
        self.v = PCOutlineView(d: d, c: c, content: content)
    }
    
    var body: some View {
        List {
            v
        }
    }
}

struct PCOutlineView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {
    let d: D
    let c: KeyPath<D.Element, D?>
    let content: (D.Element) -> Content
    @State var isExpanded = true // 控制初始是否展开的状态
    
    var body: some View {
        ForEach(d) { i in
            if let sub = i[keyPath: c] {
                PCDisclosureGroup(content: PCOutlineView(d: sub, c: c, content: content), label: content(i))
            } else {
                content(i)
            } // end if
        } // end ForEach
    } // end body
}

struct PCDisclosureGroup<C, L>: View where C: View, L: View {
    @State var isExpanded = false
    var content: C
    var label: L
    var body: some View {
        DisclosureGroup(isExpanded: $isExpanded) {
            content
        } label: {
            Button {
                isExpanded.toggle()
            } label: {
                label
            }
            .buttonStyle(.plain)
        }
    }
}

// MARK: - 大纲模式数据模型
struct POutlineModel: Hashable, Identifiable {
    var id = UUID()
    var title: String
    var iconName: String
    var children: [POutlineModel]?
}

// MARK: - List 的抽象,数据兼容任何集合类型
struct PCustomListView<D: RandomAccessCollection & MutableCollection & RangeReplaceableCollection, Content: View>: View where D.Element: Identifiable {
    @Binding var data: D
    var content: (Binding<D.Element>) -> Content
    
    init(_ data: Binding<D>, content: @escaping (Binding<D.Element>) -> Content) {
        self._data = data
        self.content = content
    }
    
    var body: some View {
        List {
            Section {
                ForEach($data, content: content)
                    .onMove { indexSet, offset in
                        data.move(fromOffsets: indexSet, toOffset: offset)
                    }
                    .onDelete { indexSet in
                        data.remove(atOffsets: indexSet) // macOS 暂不支持
                    }
            } header: {
                Text("第一栏,共 \(data.count) 项")
            } footer: {
                Text("The End")
            }
        }
        .listStyle(.plain) // 有.automatic、.inset、.plain、sidebar,macOS 暂不支持的有.grouped 和 .insetGrouped
    }
}

// MARK: - Cell 视图
struct PRowView: View {
    var s: String
    var i: Int
    var body: some View {
        HStack {
            Text("\(i):")
            Text(s)
        }
    }
}

// MARK: - 数据模型设计
struct PLModel: Hashable, Identifiable {
    let id = UUID()
    var s: String
    var i: Int
}

final class PLVM: ObservableObject {
    @Published var ls: [PLModel]
    init() {
        ls = [PLModel]()
        for i in 0...20 {
            ls.append(PLModel(s: "\(i)", i: i))
        }
    }
}

list 支持 Section footer。

list 分隔符可以自定义,使用 HorizontalEdge.leadingHorizontalEdge.trailing

list 不使用 UITableView 了。

今年 list 还新增了一个 EditOperation 可以自动生成移动和删除,新增了 edits 参数,传入 [.delete, .move] 数组即可。这也是一个演示如何更好扩展和配置功能的方式。

.searchable 支持 token 和 scope,示例如下:

swift 复制代码
struct PSearchTokensAndScopes: View {
    enum AttendanceScope {
        case inPerson, online
    }
    @State private var queryText: String
    @State private var queryTokens: [InvitationToken]
    @State private var scope: AttendanceScope
    
    var body: some View {
        invitationCountView()
            .searchable(text: $queryText, tokens: $queryTokens, scope: $scope) { token in
                Label(token.diplayName, systemImage: token.systemImage)
            } scopes: {
                Text("In Person").tag(AttendanceScope.inPerson)
                Text("Online").tag(AttendanceScope.online)
            }
    }
}

List-设置样式

内置样式

通过 .listStyle 修饰符可以用系统内置样式更改 List 外观。

swift 复制代码
List {
   ...
}
.listStyle(.sidebar)

不同平台有不同的选项

ListStyle iOS macOS watchOS tvOS
plain iOS 13+ macOS 10.15+ watchOS 6+ tvOS 13+
sidebar iOS 14+ macOS 10.15+ - -
inset iOS 13+ macOS 11.15+ - -
grouped iOS 13+ - - tvOS 13+
insetGrouped iOS 14+ - - -
bordered - macOS 12+ - -
carousel - - watchOS 6+ -
elliptical - - watchOS 7+ -

行高

swift 复制代码
List {
  ...
}
.environment(\.defaultMinListRowHeight, 100)
.environment(\.defaultMinListHeaderHeight, 50)

分隔符

listSectionSeparator 和 listRowSeparator 隐藏行和 Section 分隔符。

listRowSeparatorTint 和 listSectionSeparatorTint 更改分隔符颜色

例如:

swift 复制代码
.listRowSeparatorTint(.cyan, edges: .bottom)

背景

.alternatingRowBackgrounds() 可以让 List 的行底色有区分。

listRowBackground 调整行的背景颜色

更改背景颜色前需要隐藏内容背景

swift 复制代码
List {
  ...
}
.scrollContentBackground(.hidden)
.background(Color.cyan)

这个方法同样可用于 ScrollView 和 TextEditor。

你可以使用 .listRowBackground() 修饰符来更改列表行的背景。以下是一个例子:

swift 复制代码
struct ContentView: View {
    var body: some View {
        List {
            ForEach(0..<5) { index in
                Text("Row \(index)")
                    .listRowBackground(index % 2 == 0 ? Color.blue : Color.green)
            }
        }
    }
}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .listRowBackground() 修饰符来更改每个元素的背景颜色。如果元素的索引是偶数,我们将背景颜色设置为蓝色,否则我们将背景颜色设置为绿色。

Section

你可以使用 Section 视图的 headerfooter 参数来添加头部和尾部。以下是一个例子:

swift 复制代码
struct ContentView: View {
    var body: some View {
        List {
            Section {
                ForEach(0..<5) { index in
                    Text("Row \(index)")
                }
            } header: {
                Text("Header").font(.title)
            } footer: {
                Text("Footer").font(.caption)
            }
        }
    }
}

headerProminence(.increase) 可以增加 Section Header 的大小。

safeAreaInset

你可以使用 .safeAreaInset() 修饰符来调整视图的安全区域插入。以下是一个例子:

swift 复制代码
struct ContentView: View {
    var body: some View {
        List {
            ForEach(0..<5) { index in
                Text("Row \(index)")
            }
        }
        .safeAreaInset(edge: .top, spacing: 20) {
            Text("Header")
                .frame(maxWidth: .infinity, alignment: .center)
                .background(Color.blue)
                .foregroundColor(.white)
        }
    }
}

在这个例子中,我们创建了一个包含五个元素的 List。然后我们使用 .safeAreaInset() 修饰符来在 List 的顶部添加一个 Header。我们将 edge 参数设置为 .top,将 spacing 参数设置为 20,然后提供一个视图作为 Header。这个 Header 是一个文本视图,它的背景颜色是蓝色,前景颜色是白色,它被居中对齐,并且它的宽度和 List 的宽度相同。

List-移动元素

你可以使用 .onMove(perform:) 修饰符来允许用户移动 List 中的元素。以下是一个例子:

swift 复制代码
struct ContentView: View {
    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item)
                }
                .onMove(perform: move)
            }
            .toolbar {
                EditButton()
            }
        }
    }

    private func move(from source: IndexSet, to destination: Int) {
        items.move(fromOffsets: source, toOffset: destination)
    }
}

在这个例子中,我们创建了一个包含五个元素的 List。我们使用 .onMove(perform:) 修饰符来允许用户移动这些元素,并提供了一个 move(from:to:) 方法来处理移动操作。我们还添加了一个 EditButton,用户可以点击它来进入编辑模式,然后就可以移动元素了。

List-搜索

搜索和搜索建议

你可以使用 .searchable() 修饰符的 suggestions 参数来提供搜索建议。以下是一个例子:

swift 复制代码
struct ContentView: View {
    @State private var searchText = ""
    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]

    var body: some View {
        NavigationView {
            List {
                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in
                    Text(item)
                }
            }
            .searchable(text: $searchText, suggestions: { 
                Button(action: {
                    searchText = "Item 1"
                }) {
                    Text("Item 1")
                }
                Button(action: {
                    searchText = "Item 2"
                }) {
                    Text("Item 2")
                }
            })
            .navigationBarTitle("Items")
        }
    }
}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了一个搜索框。当用户在搜索框中输入文本时,List 会自动更新以显示匹配的元素。同时,我们提供了两个搜索建议 "Item 1" 和 "Item 2",用户可以点击这些建议来快速填充搜索框。

在列表中显示搜索建议

swift 复制代码
struct ContentView: View {
    @Environment(\.searchSuggestionsPlacement) var placement
    @State private var searchText = ""
    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]
    
    var body: some View {
        NavigationView {
            List {
                SearchSuggestionView()
                ForEach(items.filter({ searchText.isEmpty ? true : $0.contains(searchText) }), id: \.self) { item in
                    Text(item)
                }
            }
            .searchable(text: $searchText, suggestions: {
                VStack {
                    Button(action: {
                        searchText = "Item 1"
                    }) {
                        Text("Item 1")
                    }
                    Button(action: {
                        searchText = "Item 2"
                    }) {
                        Text("Item 2")
                    }
                }
                .searchSuggestions(.hidden, for: .content)
            })
            .navigationBarTitle("Items")
        }
    }
    
    @ViewBuilder
    func SearchSuggestionView() -> some View {
        if placement == .content {
            Button(action: {
                searchText = "Item 1"
            }) {
                Text("Item 1")
            }
            Button(action: {
                searchText = "Item 2"
            }) {
                Text("Item 2")
            }
        }
    }
}

搜索状态

搜索中

swift 复制代码
@Environment(\.isSearching) var isSearching

关闭搜索

swift 复制代码
@Environment(\.dismissSearch) var dismissSearch

提交搜索

swift 复制代码
List {
    ...
}
.searchable(text: $vm.searchTerm)
.onSubmit(of: .search) {
    //...
}

搜索栏外观

占位文字说明

swift 复制代码
.searchable(text: $wwdcVM.searchText, prompt: "搜索 WWDC Session 内容")

一直显示搜索栏

swift 复制代码
.searchable(text: $wwdcVM.searchText, 
            placement: .navigationBarDrawer(displayMode:.always))

更改搜索栏的位置

swift 复制代码
.searchable(text: $wwdcVM.searchText, placement: .sidebar)

搜索去抖动

你可以使用 Combine 框架来实现搜索的去抖动功能。以下是一个例子:

swift 复制代码
import SwiftUI
import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var searchResults: [String] = []

    private var cancellables = Set<AnyCancellable>()

    init() {
        $searchText
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .sink { [weak self] in self?.search($0) }
            .store(in: &cancellables)
    }

    private func search(_ text: String) {
        // 这里是你的搜索逻辑
        // 例如,你可以从一个数组中过滤出匹配的元素
        let items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]
        searchResults = items.filter { $0.contains(text) }
    }
}

struct ContentView: View {
    @StateObject private var viewModel = SearchViewModel()

    var body: some View {
        VStack {
            TextField("Search", text: $viewModel.searchText)
                .padding()
            List(viewModel.searchResults, id: \.self) { result in
                Text(result)
            }
        }
    }
}

在这个例子中,我们创建了一个 SearchViewModel 类,它有一个 searchText 属性和一个 searchResults 属性。当 searchText 属性的值发生变化时,我们使用 Combine 的 debounce(for:scheduler:) 方法来延迟执行搜索操作,从而实现去抖动功能。然后我们在 ContentView 中使用这个 SearchViewModel 来显示搜索框和搜索结果。

List-下拉刷新

你可以使用 .refreshable() 修饰符来添加下拉刷新功能。以下是一个例子:

swift 复制代码
struct ContentView: View {
    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
            }
        }
        .refreshable {
            await refresh()
        }
    }

    func refresh() async {
        // 这里是你的刷新逻辑
        // 例如,你可以从网络获取新的数据,然后更新 items 数组
        // 这里我们只是简单地将 items 数组反转
        items.reverse()
    }
}

在这个例子中,我们创建了一个包含五个元素的 List,并添加了下拉刷新功能。当用户下拉 List 时,refresh() 方法会被调用,然后我们将 items 数组反转,从而模拟刷新操作。注意,refresh() 方法需要是一个异步方法,因为刷新操作通常需要一些时间来完成。

List-轻扫操作

你可以使用 .swipeActions() 修饰符来添加轻扫操作。以下是一个例子:

swift 复制代码
struct ContentView: View {
    @State private var items = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
                .swipeActions {
                    Button(action: {
                        // 这里是你的删除操作
                        if let index = items.firstIndex(of: item) {
                            items.remove(at: index)
                        }
                    }) {
                        Label("Delete", systemImage: "trash")
                    }
                    .tint(.red)
                }
            }
        }
    }
}

在这个例子中,我们创建了一个包含五个元素的 List,并为每个元素添加了一个滑动操作。当用户向左轻扫一个元素时,会显示一个 "Delete" 按钮,用户可以点击这个按钮来删除该元素。

List-大纲视图

List 树状结构

通过 children 参数指定子树路径。

swift 复制代码
List(outlineModel, children: \.children) { i in
    Label(i.title, systemImage: i.iconName)
}

DisclosureGroup 实现展开和折叠

DisclosureGroup 视图可以用来创建一个可以展开和折叠的内容区域。以下是一个例子:

swift 复制代码
struct ContentView: View {
    @State private var isExpanded = false

    var body: some View {
        DisclosureGroup("Options", isExpanded: $isExpanded) {
            Text("Option 1")
            Text("Option 2")
            Text("Option 3")
        }
    }
}

在这个例子中,我们创建了一个 DisclosureGroup 视图,它的标题是 "Options",并且它包含三个选项。我们使用一个 @State 属性 isExpanded 来控制 DisclosureGroup 视图是否展开。当用户点击标题时,DisclosureGroup 视图会自动展开或折叠,同时 isExpanded 属性的值也会相应地改变。

OutlineGroup 创建大纲视图

可以使用 OutlineGroup 视图来创建一个大纲视图。以下是一个例子:

swift 复制代码
struct ContentView: View {
    var body: some View {
        List {
            OutlineGroup(sampleData, id: \.self) { item in
                Text(item.name)
            }
        }
    }
}

struct Item: Identifiable {
    var id = UUID()
    var name: String
    var children: [Item]?
}

let sampleData: [Item] = [
    Item(name: "Parent 1", children: [
        Item(name: "Child 1"),
        Item(name: "Child 2")
    ]),
    Item(name: "Parent 2", children: [
        Item(name: "Child 3"),
        Item(name: "Child 4")
    ])
]

在这个例子中,我们创建了一个 Item 结构体,它有一个 name 属性和一个 children 属性。然后我们创建了一个 sampleData 数组,它包含两个父项,每个父项都有两个子项。最后我们在 ContentView 中使用 OutlineGroup 视图来显示这个数组,每个父项和子项都显示为一个文本视图。

结合 OutlineGroup 和 DisclosureGroup 实现自定义可折叠大纲视图

代码如下:

swift 复制代码
struct SPOutlineListView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {
    private let v: SPOutlineView<D, Content>
    
    init(d: D, c: KeyPath<D.Element, D?>, content: @escaping (D.Element) -> Content) {
        self.v = SPOutlineView(d: d, c: c, content: content)
    }
    
    var body: some View {
        List {
            v
        }
    }
}

struct SPOutlineView<D, Content>: View where D: RandomAccessCollection, D.Element: Identifiable, Content: View {
    let d: D
    let c: KeyPath<D.Element, D?>
    let content: (D.Element) -> Content
    @State var isExpanded = true // 控制初始是否展开的状态
    
    var body: some View {
        ForEach(d) { i in
            if let sub = i[keyPath: c] {
                SPDisclosureGroup(content: SPOutlineView(d: sub, c: c, content: content), label: content(i))
            } else {
                content(i)
            } // end if
        } // end ForEach
    } // end body
}

struct SPDisclosureGroup<C, L>: View where C: View, L: View {
    @State var isExpanded = false
    var content: C
    var label: L
    var body: some View {
        DisclosureGroup(isExpanded: $isExpanded) {
            content
        } label: {
            Button {
                withAnimation {
                    isExpanded.toggle()
                }
            } label: {
                label
            }
            .buttonStyle(.plain)
        }
        
    }
}

List-完全可点击的行

使用 .contentShape(Rectangle()) 可以使整个区域都可点击

swift 复制代码
struct ContentView: View {
    var body: some View {
        List {
            ForEach(1..<50) { num in
                HStack {
                    Text("\(num)")
                    Spacer()
                }
                .contentShape(Rectangle())
                .onTapGesture {
                    print("Clicked \(num)")
                }
            }
        } // end list
    }
}

List-索引标题

这个代码是在创建一个带有索引标题的列表,用户可以通过拖动索引标题来快速滚动列表。

swift 复制代码
import SwiftUI

...

struct ContentView: View {
  ...
  var body: some View {
    ScrollViewReader { proxy in
      List {
        ArticleListView
      }
      .listStyle(InsetGroupedListStyle())
      .overlay(IndexView(proxy: proxy))
    }
  }
  ...
}

struct IndexView: View {
  let proxy: ScrollViewProxy
  let titles: [String]
  @GestureState private var dragLocation: CGPoint = .zero

  var body: some View {
    VStack {
      ForEach(titles, id: \.self) { title in
        TitleView()
          .background(drag(title: title))
      }
    }
    .gesture(
      DragGesture(minimumDistance: 0, coordinateSpace: .global)
        .updating($dragLocation) { value, state, _ in
          state = value.location
        }
    )
  }

  func drag(title: String) -> some View {
    GeometryReader { geometry in
      drag(geometry: geometry, title: title)
    }
  }

  func drag(geometry: GeometryProxy, title: String) -> some View {
    if geometry.frame(in: .global).contains(dragLocation) {
      DispatchQueue.main.async {
        proxy.scrollTo(title, anchor: .center)
      }
    }
    return Rectangle().fill(Color.clear)
  }
  ...
}
...

上面代码中 ContentView 是主视图,它包含一个 List 和一个 IndexViewList 中的内容由 ArticleListView 提供。IndexView 是一个自定义视图,它显示了所有的索引标题。

IndexView 接受一个 ScrollViewProxy 和一个标题数组。它使用 VStackForEach 来创建一个垂直的索引标题列表。每个标题都是一个 TitleView,并且它有一个背景,这个背景是通过 drag(title:) 方法创建的。

drag(title:) 方法接受一个标题,并返回一个视图。这个视图是一个 GeometryReader,它可以获取其包含的视图的几何信息。然后,这个 GeometryReader 使用 drag(geometry:title:) 方法来创建一个新的视图。

drag(geometry:title:) 方法接受一个 GeometryProxy 和一个标题,并返回一个视图。如果 GeometryProxy 的全局帧包含当前的拖动位置,那么这个方法将返回一个特定的视图。

IndexView 还有一个手势,这个手势是一个 DragGesture。当用户拖动索引标题时,这个手势会更新 dragLocation 属性的值,这个属性是一个 @GestureState 属性,它表示当前的拖动位置。

List-加载更多

你可以通过检测列表滚动到底部来实现加载更多的功能。以下是一个简单的例子:

swift 复制代码
struct ContentView: View {
    @State private var items = Array(0..<20)

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text("Item \(item)")
                    .onAppear {
                        if item == items.last {
                            loadMore()
                        }
                    }
            }
        }
        .onAppear(perform: loadMore)
    }

    func loadMore() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            let newItems = Array(self.items.count..<self.items.count + 20)
            self.items.append(contentsOf: newItems)
        }
    }
}

在这个例子中,我们创建了一个包含多个元素的 List。当 List 出现最后一项时,我们调用 loadMore 方法来加载更多的元素。在 loadMore 方法中,模拟在一秒后添加新的元素到 items 数组中。

请注意,这只是一个基本的使用示例,实际的使用方式可能会根据你的需求而变化。例如,你可能需要从网络获取新的元素,而不是像这个例子中那样直接创建新的元素。

Lazy容器

LazyVStack和LazyHStack

LazyVStack 和 LazyHStack 里的视图只有在滚到时才会被创建。

swift 复制代码
struct PlayLazyVStackAndLazyHStackView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(1...300, id: \.self) { i in
                    PLHSRowView(i: i)
                }
            }
        }
    }
}

struct PLHSRowView: View {
    let i: Int
    var body: some View {
        Text("第 \(i) 个")
    }
    init(i: Int) {
        print("第 \(i) 个初始化了") // 用来查看什么时候创建的。
        self.i = i
    }
}

LazyVGrid和LazyHGrid

列的设置有三种,这三种也可以组合用。

  • GridItem(.fixed(10)) 会固定设置有多少列。
  • GridItem(.flexible()) 会充满没有使用的空间。
  • GridItem(.adaptive(minimum: 10)) 表示会根据设置大小自动设置有多少列展示。

示例:

swift 复制代码
struct PlayLazyVGridAndLazyHGridView: View {
    @State private var colors: [String:Color] = [
        "red" : .red,
        "orange" : .orange,
        "yellow" : .yellow,
        "green" : .green,
        "mint" : .mint,
        "teal" : .teal,
        "cyan" : .cyan,
        "blue" : .blue,
        "indigo" : .indigo,
        "purple" : .purple,
        "pink" : .pink,
        "brown" : .brown,
        "gray" : .gray,
        "black" : .black
    ]
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [
                GridItem(.adaptive(minimum: 50), spacing: 10)
            ], pinnedViews: [.sectionHeaders]) {
                Section(header:
                            Text("🎨调色板")
                            .font(.title)
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                                .background(RoundedRectangle(cornerRadius: 0)
                                                .fill(.black.opacity(0.1)))
                ) {
                    ForEach(Array(colors.keys), id: \.self) { k in
                        colors[k].frame(height:Double(Int.random(in: 50...150)))
                            .overlay(
                                Text(k)
                            )
                            .shadow(color: .black, radius: 2, x: 0, y: 2)
                    }
                }
            }
            .padding()
            
            LazyVGrid(columns: [
                GridItem(.adaptive(minimum: 20), spacing: 10)
            ]) {
                Section(header: Text("图标集").font(.title)) {
                    ForEach(1...30, id: \.self) { i in
                        Image("p\(i)")
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .shadow(color: .black, radius: 2, x: 0, y: 2)
                    }
                }
            }
            .padding()
        }
    }
}

Grid

Grid 会将最大的一个单元格大小应用于所有单元格

代码例子:

swift 复制代码
struct ContentView: View {
    var body: some View {
        Grid(alignment: .center,
             horizontalSpacing: 30,
             verticalSpacing: 8) {
            GridRow {
                Text("Tropical")
                Text("Mango")
                Text("Pineapple")
                    .gridCellColumns(2)
            }
            GridRow(alignment: .bottom) {
                Text("Leafy")
                Text("Spinach")
                Text("Kale")
                Text("Lettuce")
            }
        }
    }
}

gridCellAnchor 可以让 GridRow 给自己设置对齐方式。

gridCellColumns() modifier 可以让一个单元格跨多列。

GridRow 的间距通过 Grid 的 horizontalSpacingverticalSpacing 参数来控制。

swift 复制代码
struct ContentView: View {
    let numbers: [[Int]] = [
        [1, 2, 3],
        [4, 5, 6],
        [7, 8, 9]
    ]

    var body: some View {
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            ForEach(numbers.indices, id: \.self) { i in
                GridRow {
                    ForEach(numbers[i].indices, id: \.self) { j in
                        Text("\(numbers[i][j])")
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                            .background(Color.gray.opacity(0.2))
                            .border(Color.gray, width: 0.5)
                    }
                }
            }
        }
    }
}

按照以上代码这样写,每个数字 GridRow 之间的间隔就是0了。

空白的单元格可以这样写:

swift 复制代码
Color.clear
    .gridCellUnsizedAxes([.horizontal, .vertical])

Table表格

Table

今年 iOS 和 iPadOS 也可以使用去年只能在 macOS 上使用的 Table了,据 digital lounges 里说,iOS table 的性能和 list 差不多,table 默认为 plian list。我想 iOS 上加上 table 只是为了兼容 macOS 代码吧。

table 使用示例如下:

swift 复制代码
struct ContentView: View {
    var body: some View {
        Table(Fruit.simpleData()) {
            TableColumn("名字", value: \.name)
            TableColumn("颜色", value: \.color)
            TableColumn("颜色") {
                Text("\($0.name)")
                    .font(.footnote)
                    .foregroundStyle(.cyan)
            }
        }
        .contextMenu(forSelectionType: Fruit.ID.self) { selection in
            if selection.isEmpty {
                Button("添加") {
                    // ...
                }
            } else if selection.count == 1 {
                Button("收藏") {
                    // ...
                }
            } else {
                Button("收藏多个") {
                    // ...
                }
            }
        }
    }
    
    struct Fruit:Identifiable {
        let id = UUID()
        let name: String
        let color: String
        
        static func simpleData() -> [Fruit] {
            var re = [Fruit]()
            re.append(Fruit(name: "Apple", color: "Red"))
            re.append(Fruit(name: "Banana", color: "Yellow"))
            re.append(Fruit(name: "Cherry", color: "Red"))
            re.append(Fruit(name: "Date", color: "Brown"))
            re.append(Fruit(name: "Elderberry", color: "Purple"))
            return re
        }
    }
}

Table-样式

在 SwiftUI 中,Table 视图的 .tableStyle 修改器可以用来设置表格的样式。目前,SwiftUI 提供了以下几种表格样式:

  • inset:默认
  • inset(alternatesRowBackgrounds: Bool):是否开启行交错背景
  • bordered:加边框
  • bordered(alternatesRowBackgrounds: Bool): 是否开启行交错背景

你可以使用 .tableStyle 修改器来设置表格的样式,例如:

swift 复制代码
Table(data) {
    // ...
}
.tableStyle(InsetGroupedListStyle())

这段代码会将表格的样式设置为 InsetGroupedListStyle

Table-行的选择

你可以使用 Table 视图的 selection 参数来实现单选和多选。selection 参数接受一个绑定到一个可选的 Set 的变量,这个 Set 包含了被选中的元素的标识。

以下是一个使用 Table 视图实现单选和多选的例子:

swift 复制代码
struct ContentView: View {
    @State private var selectionOne: UUID? // 单选
    @State private var selection: Set<UUID> = [] // 多选

    let data = [
        Fruit(name: "Apple", color: "Red"),
        Fruit(name: "Banana", color: "Yellow"),
        Fruit(name: "Cherry", color: "Red"),
        Fruit(name: "Date", color: "Brown"),
        Fruit(name: "Elderberry", color: "Purple")
    ]

    var body: some View {
        Table(data, selection: $selectionOne) {
            TableColumn("Fruit") { item in
                Text(item.name)
            }
            TableColumn("Color") { item in
                Text(item.color)
            }
        }
    }
}

struct Fruit: Identifiable {
    let id = UUID()
    let name: String
    let color: String
}

在这个例子中,我们首先定义了一个 @State 变量 selection,它是一个 Set,包含了被选中的元素的标识。然后,我们将这个变量绑定到 Table 视图的 selection 参数。

现在,当用户选择或取消选择一个元素时,selection 变量就会被更新。你可以使用这个变量来判断哪些元素被选中,或者实现其他的交互功能。

Table-多属性排序

你可以使用 Table 视图的 sortOrder 参数来实现多属性排序。sortOrder 参数接受一个绑定到一个 SortDescriptor 数组的变量,这个数组定义了排序的顺序和方式。

以下是一个使用 Table 视图实现多属性排序的例子:

swift 复制代码
struct ContentView: View {
    @State private var sortOrder: [KeyPathComparator<Fruit>] = [.init(\.name, order: .reverse)]

    @State var data = [
        Fruit(name: "Apple", color: "Red"),
        Fruit(name: "Banana", color: "Yellow"),
        Fruit(name: "Cherry", color: "Red"),
        Fruit(name: "Date", color: "Brown"),
        Fruit(name: "Elderberry", color: "Purple")
    ]

    var body: some View {
        sortKeyPathView() // 排序状态
        Table(data, sortOrder: $sortOrder) {
            TableColumn("Fruit", value: \.name)
            TableColumn("Color", value: \.color)
            // 不含 value 参数的不支持排序
            TableColumn("ColorNoOrder") {
                Text("\($0.color)")
                    .font(.footnote)
                    .foregroundStyle(.mint)
            }
        }
        .task {
            data.sort(using: sortOrder)
        }
        .onChange(of: sortOrder) { oldValue, newValue in
            data.sort(using: newValue)
        }
        .padding()
    }
    
    @ViewBuilder
    func sortKeyPathView() -> some View {
        HStack {
            ForEach(sortOrder, id: \.self) { order in
                Text(order.keyPath == \Fruit.name ? "名字" : "颜色")
                Image(systemName: order.order == .reverse ? "chevron.down" : "chevron.up")
            }
        }
        .padding(.top)
    }
}

struct Fruit: Identifiable {
    let id = UUID()
    let name: String
    let color: String
}

在这个例子中,我们首先定义了一个 @State 变量 sortOrder,它是一个 SortDescriptor 数组,定义了排序的顺序和方式。然后,我们将这个变量绑定到 Table 视图的 sortOrder 参数。

现在,当用户点击表头来排序一个列时,sortOrder 变量就会被更新。你可以使用这个变量来实现多属性排序,或者实现其他的交互功能。

Table-contextMenu

swift 复制代码
struct ContentView: View {
    @State private var selection: Set<UUID> = []
    var body: some View {
        Table(Fruit.simpleData(), selection: $selection) {
            ...
        }
        .contextMenu(forSelectionType: Fruit.ID.self) { selection in
            if selection.isEmpty {
                Button("添加") {
                    // ...
                }
            } else if selection.count == 1 {
                Button("收藏") {
                    // ...
                }
            } else {
                Button("收藏多个") {
                    // ...
                }
            }
        } primaryAction: { items in
            // 双击某一行时
            debugPrint(items)
        }
    }
    ...
}
相关推荐
报错小能手21 小时前
ios开发方向——swift并发进阶核心 Task、Actor、await 详解
开发语言·学习·ios·swift
用户79457223954132 天前
【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身
objective-c·swift
报错小能手2 天前
SwiftUI 框架 认识 SwiftUI 视图结构 + 布局
ui·ios·swift
东坡肘子2 天前
被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131
人工智能·swiftui·swift
报错小能手3 天前
ios开发方向——swift错误处理:do/try/catch、Result、throws
开发语言·学习·ios·swift
小夏子_riotous3 天前
openstack的使用——5. Swift服务的基本使用
linux·运维·开发语言·分布式·云计算·openstack·swift
mCell3 天前
MacOS 下实现 AI 操控电脑(Computer Use)的思考
macos·agent·swift
用户79457223954133 天前
【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线
swiftui·swift
chaoguo12343 天前
Any metadata 的内存布局
swift·metadata·value witness table
tangweiguo030519875 天前
SwiftUI布局完全指南:从入门到精通
ios·swift