Grid

Grid 视图,灵感来自 CSS Grid,使用 SwiftUI 编写

阅读文章 »

SPM Compatible Cocoapods Compatible Carthage Compatible License: MIT

概述

Grid 是一种在 SwiftUI 中布局视图的强大而简便的方法

请查看下面的完整文档

安装

CocoaPods

Grid 可通过 CocoaPods 获得。要安装它,只需将以下行添加到您的 Podfile 中

pod 'ExyteGrid'

Swift Package Manager

Grid 可通过 Swift Package Manager 获得。

将其作为包依赖项添加到现有的 Xcode 项目

  1. 文件菜单中,选择Swift Packages › Add Package Dependency…
  2. 在包存储库 URL 文本字段中输入“https://github.com/exyte/Grid

要求

从源代码构建

git clone git@github.com:exyte/Grid.git
cd Grid/Example/
pod install
open Example.xcworkspace/

文档

1. 初始化

您可以通过不同的方式实例化 Grid

  1. 只需指定轨道并在 ViewBuilder 闭包中指定您的视图
Grid(tracks: 3) {
    ColorView(.blue)
    ColorView(.purple)
    ColorView(.red)
    ColorView(.cyan)
    ColorView(.green)
    ColorView(.orange)
}
  1. 使用 Range
Grid(0..<6, tracks: 3) { _ in
    ColorView(.random)
}
  1. 使用 Identifiable 实体
Grid(colorModels, tracks: 3) {
    ColorView($0)
}
  1. 使用显式定义的 ID
Grid(colorModels, id: \.self, tracks: 3) {
    ColorView($0)
}

2. 容器

ForEach

在 ViewBuilder 中,您还可以使用常规的 ForEach 语句。无法从初始化的 ForEach 视图中获取 KeyPath id 值。在执行动画时,其内部内容将通过视图顺序来区分。最好将 ForEachIdentifiable 模型或 GridGroup 一起使用,GridGroup 可以使用显式 ID 值或 Identifiable 模型创建,以便跟踪网格视图及其在动画中的 View 表示。

Grid(tracks: 4) {
    ColorView(.red)
    ColorView(.purple)
    
    ForEach(0..<4) { _ in
        ColorView(.black)
    }

    ColorView(.orange)
    ColorView(.green)
}

GridGroup

ViewBuilder 闭包中的视图数量限制为 10。无法从常规 SwiftUI Group 视图中获取内容视图。要超过此限制,您可以使用 GridGroupGridGroup 中的每个视图都作为单独的网格项放置。与 Group 视图不同,对 GridView 的任何外部方法修改都不会应用于子视图。因此它只是一个可枚举的容器。此外,GridGroup 可以通过 Range<Int>Identifiable 模型,或者通过显式指定的 ID 创建。

您还可以使用 GridGroup 将视图的标识绑定到给定的单个 HashableIdentifiable 值。这将生成具有相同标识的新视图的过渡动画。

无法使用 View 的 .id() 修饰符,因为内部 ForEach 视图会清除该值

您可以使用 GridGroup.empty 来定义内容的缺失。

示例

var arithmeticButtons: GridGroup {
    GridGroup {
        CalcButton(.divide)
        CalcButton(.multiply)
        CalcButton(.substract)
        CalcButton(.equal)
    }
}
var arithmeticButtons: GridGroup {
    let operations: [MathOperation] =
        [.divide, .multiply, .substract, .add, .equal]
	
    return GridGroup(operations, id: \.self) {
        CalcButton($0)
    }
}
var arithmeticButtons: GridGroup {
    let operations: [MathOperation] =
        [.divide, .multiply, .substract, .add, .equal]
	
    return GridGroup {
        ForEach(operations, id: \.self) {
            CalcButton($0)
        }
    }
}
var arithmeticButtons: GridGroup {
    let operations: [MathOperation] =
        [.divide, .multiply, .substract, .add, .equal]
    return GridGroup(operations, id: \.self) { 
         CalcButton($0)
    }
}
var arithmeticButtons: GridGroup {
    let operations: [MathOperation] =
        [.divide, .multiply, .substract, .add, .equal]
    return GridGroup(operations, id: \.self) { 
         CalcButton($0)
    }
}
Grid {
...
    GridGroup(MathOperation.clear) {
        CalcButton($0)
    }
}

3. 轨道尺寸

您可以混合使用 3 种类型的轨道尺寸

固定大小的轨道

.pt(N),其中 N - 点数。

Grid(tracks: [.pt(50), .pt(200), .pt(100)]) {
    ColorView(.blue)
    ColorView(.purple)
    ColorView(.red)
    ColorView(.cyan)
    ColorView(.green)
    ColorView(.orange)
}

基于内容的大小:.fit

将轨道尺寸定义为轨道中每个视图的内容尺寸的最大值

Grid(0..<6, tracks: [.fit, .fit, .fit]) {
    ColorView(.random)
        .frame(maxWidth: 50 + 15 * CGFloat($0))
}

请注意限制视图的大小,这些视图会填充父级提供的整个空间以及倾向于绘制为单行的 Text() 视图。

灵活大小的轨道:.fr(N)

Fr 是一个分数单位,.fr(1) 用于网格中未分配空间的 1 部分。在所有非灵活大小的轨道(.pt.fit)之后,才会计算灵活大小的轨道。因此,可用于分配的可用空间是总可用尺寸与非灵活轨道尺寸之和的差值。

Grid(tracks: [.pt(100), .fr(1), .fr(2.5)]) {
    ColorView(.blue)
    ColorView(.purple)
    ColorView(.red)
    ColorView(.cyan)
    ColorView(.green)
    ColorView(.orange)
}

此外,您可以仅指定一个 Int 字面量作为轨道尺寸。它等于重复 .fr(1) 轨道尺寸

Grid(tracks: 3) { ... }

等于

Grid(tracks: [.fr(1), .fr(1), .fr(1)]) { ... }

4. Grid 单元格背景和叠加层

当使用非灵活轨道尺寸时,要分配的额外空间可能大于网格项能够占用的空间。要填充该空间,您可以使用 .gridCellBackground(...)gridCellOverlay(...) 修饰符。

请参阅 内容模式间距 示例。


5. 跨度

每个网格视图都可以跨越提供的网格轨道数。您可以使用 .gridSpan(column: row:) 修饰符来实现此目的。默认跨度为 1。

跨度 >= 2 的视图,跨越具有灵活大小的轨道,不参与这些轨道的尺寸分配。此视图将适合跨越的轨道。因此,可以放置一个具有无限大小的视图,该视图跨越具有基于内容的尺寸(.fit)的轨道

Grid(tracks: [.fr(1), .pt(150), .fr(2)]) {
    ColorView(.blue)
        .gridSpan(column: 2)
    ColorView(.purple)
        .gridSpan(row: 2)
    ColorView(.red)
    ColorView(.cyan)
    ColorView(.green)
        .gridSpan(column: 2, row: 3)
    ColorView(.orange)
    ColorView(.magenta)
        .gridSpan(row: 2)
}

跨越具有不同尺寸类型的轨道

var body: some View {
    Grid(tracks: [.fr(1), .fit, .fit], spacing: 10) {
        VCardView(text: placeholderText(),
                  color: .red)
        
        VCardView(text: placeholderText(length: 30),
                  color: .orange)
            .frame(maxWidth: 70)
        
        VCardView(text: placeholderText(length: 120),
                  color: .green)
            .frame(maxWidth: 100)
            .gridSpan(column: 1, row: 2)
        
        VCardView(text: placeholderText(length: 160),
                  color: .magenta)
            .gridSpan(column: 2, row: 1)
        
        VCardView(text: placeholderText(length: 190),
                  color: .cyan)
            .gridSpan(column: 3, row: 1)
    }
}

6. 起始位置

对于每个视图,您都可以通过指定列、行或两者来设置显式起始位置。如果没有指定起始位置,则将自动定位视图。首先,放置具有列和行起始位置的视图。其次,自动放置算法尝试放置具有列或行起始位置的视图。如果存在任何冲突,则会自动放置此类视图,并且您会在控制台中看到警告。最后,放置没有显式起始位置的视图。

使用 .gridStart(column: row:) 修饰符定义起始位置。

Grid(tracks: [.pt(50), .fr(1), .fr(1.5), .fit]) {
    ForEach(0..<6) { _ in
        ColorView(.black)
    }
    
    ColorView(.brown)
        .gridSpan(column: 3)
    
    ColorView(.blue)
        .gridSpan(column: 2)
    
    ColorView(.orange)
        .gridSpan(row: 3)
    
    ColorView(.red)
        .gridStart(row: 1)
        .gridSpan(column: 2, row: 2)
    
    ColorView(.yellow)
        .gridStart(row: 2)
    
    ColorView(.purple)
        .frame(maxWidth: 50)
        .gridStart(column: 3, row: 0)
        .gridSpan(row: 9)
    
    ColorView(.green)
        .gridSpan(column: 2, row: 3)
    
    ColorView(.cyan)
    
    ColorView(.gray)
        .gridStart(column: 2)
}

7. 流向

Grid 有 2 种类型的轨道。第一种是您指定 轨道尺寸 的轨道 - 固定轨道。固定意味着轨道的数量是已知的。第二种是与固定轨道正交的增长轨道类型:您的内容在哪里增长。Grid 流向定义了项目增长的方向

默认。 列数是固定的,并且 定义为轨道尺寸。网格项在列之间移动并切换到最后一列之后的下一行时放置。行数正在增长。

行数是固定的,并且 定义为轨道尺寸。网格项在行之间移动并切换到最后一行之后的下一列时放置。列数正在增长。

可以在 grid 构造函数中以及使用 .gridFlow(...) grid 修饰符指定 Grid 流向。第一个选项具有更高的优先级。

struct ContentView: View {
    @State var flow: GridFlow = .rows
    
    var body: some View {
        VStack {
            if self.flow == .rows {
                Button(action: { self.flow = .columns }) {
                    Text("Flow: ROWS")
                }
            } else {
                Button(action: { self.flow = .rows }) {
                    Text("Flow: COLUMNS")
                }
            }
            
            Grid(0..<15, tracks: 5, flow: self.flow, spacing: 5) {
                ColorView($0.isMultiple(of: 2) ? .black : .orange)
                    .overlay(
                        Text(String($0))
                            .font(.system(size: 35))
                            .foregroundColor(.white)
                )
            }
            .animation(.default)
        }
    }
}

8. 内容模式

有两种内容模式

滚动

在此模式下,内部网格内容能够滚动到 增长方向。与网格流向(增长)正交的网格轨道被隐式地假定为具有 .fit 尺寸。这意味着它们的尺寸必须在各自的维度中定义。

可以在 grid 构造函数中以及使用 .gridContentMode(...) grid 修饰符指定 Grid 内容模式。第一个选项具有更高的优先级。

行流滚动

struct VCardView: View {
   let text: String
   let color: UIColor
   
   var body: some View {
       VStack {
           Image("dog")
               .resizable()
               .aspectRatio(contentMode: .fit)
               .cornerRadius(5)
               .frame(minWidth: 100, minHeight: 50)
           
           Text(self.text)
               .layoutPriority(.greatestFiniteMagnitude)
       }
       .padding(5)
       .gridCellBackground { _ in
           ColorView(self.color)
       }
       .gridCellOverlay { _ in
           RoundedRectangle(cornerRadius: 5)
               .strokeBorder(Color(self.color.darker()),
                             lineWidth: 3)
       }
   }
}

struct ContentView: View {
   var body: some View {
       Grid(tracks: 3) {
           ForEach(0..<40) { _ in
               VCardView(text: randomText(), color: .random)
                   .gridSpan(column: self.randomSpan)
           }
       }
       .gridContentMode(.scroll)
       .gridPacking(.dense)
       .gridFlow(.rows)
   }
   
   var randomSpan: Int {
       Int(arc4random_uniform(3)) + 1
   }
}
列流滚动

struct HCardView: View {
   let text: String
   let color: UIColor

   var body: some View {
       HStack {
           Image("dog")
               .resizable()
               .aspectRatio(contentMode: .fit)
               .cornerRadius(5)
           
           Text(self.text)
               .frame(maxWidth: 200)
       }
       .padding(5)
       .gridCellBackground { _ in
           ColorView(self.color)
       }
       .gridCellOverlay { _ in
           RoundedRectangle(cornerRadius: 5)
               .strokeBorder(Color(self.color.darker()),
                             lineWidth: 3)
       }
   }
}

struct ContentView: View {
   var body: some View {
       Grid(tracks: 3) {
           ForEach(0..<8) { _ in
               HCardView(text: randomText(), color: .random)
                   .gridSpan(row: self.randomSpan)
           }
       }
       .gridContentMode(.scroll)
       .gridFlow(.columns)
       .gridPacking(.dense)
   }
   
   var randomSpan: Int {
       Int(arc4random_uniform(3)) + 1
   }
}

填充

默认。 在此模式下,网格视图尝试使用其内容填充父视图提供的整个空间。与网格流向(增长)正交的网格轨道被隐式地假定为具有 .fr(1) 尺寸。

@State var contentMode: GridContentMode = .scroll

var body: some View {
    VStack {
        self.modesPicker
        
        Grid(models, id: \.self, tracks: 3) {
            VCardView(text: $0.text, color: $0.color)
                .gridSpan($0.span)
        }
        .gridContentMode(self.contentMode)
        .gridFlow(.rows)
        .gridAnimation(.default)
    }
}

9. 填充

自动放置算法可以坚持以下两种策略之一

稀疏

默认。 放置算法仅在放置项目时在网格中“向前”移动,永远不会回溯以填充孔。这确保了所有自动放置的项目都“按顺序”显示,即使这会留下稍后项目可以填充的孔。

密集

尝试填充网格中较早的孔,如果稍后出现较小的项目。当这样做会填充较大项目留下的孔时,这可能会导致项目看起来无序。

可以在 grid 构造函数中以及使用 .gridPacking(...) grid 修饰符指定 Grid 填充。第一个选项具有更高的优先级。

示例

@State var gridPacking = GridPacking.sparse

var body: some View {
    VStack {
        self.packingPicker

        Grid(tracks: 4) {
            ColorView(.red)
            
            ColorView(.black)
                .gridSpan(column: 4)
            
            ColorView(.purple)
  
            ColorView(.orange)
            ColorView(.green)
        }
        .gridPacking(self.gridPacking)
        .gridAnimation(.default)
    }
}

10. 间距

有几种方法可以定义轨道之间的水平和垂直间距

Grid(tracks: 4, spacing: 5) { ... } 
Grid(tracks: 4, spacing: GridSpacing(horizontal: 10, vertical: 5)) { ... } 
Grid(tracks: 4, spacing: [10, 5]) { ... } 

示例

@State var vSpacing: CGFloat = 0
@State var hSpacing: CGFloat = 0

var body: some View {
    VStack {
        self.sliders
        
        Grid(tracks: 3, spacing: [hSpacing, vSpacing]) {
            ForEach(0..<21) {
                //Inner image used to measure size
                self.image
                    .aspectRatio(contentMode: .fit)
                    .opacity(0)
                    .gridSpan(column: max(1, $0 % 4))
                    .gridCellOverlay {
                        //This one is to display
                        self.image
                            .aspectRatio(contentMode: .fill)
                            .frame(width: $0?.width, 
			           height: $0?.height)
                            .cornerRadius(5)
                            .clipped()
                            .shadow(color: self.shadowColor, 
			            radius: 10, x: 0, y: 0)
                }
            }
        }
        .background(self.backgroundColor)
        .gridContentMode(.scroll)
        .gridPacking(.dense)
    }
}

11. 对齐

.gridItemAlignment

使用此选项可为特定的单个网格项指定对齐方式。它比 gridCommonItemsAlignment 具有更高的优先级

.gridCommonItemsAlignment

gridItemAlignment 的方式应用于每个项目,但不覆盖其各自的 gridItemAlignment 值。

.gridContentAlignment

应用于整个网格内容。当内容大小小于网格的可用空间时生效。

示例

struct SingleAlignmentExample: View {
  var body: some View {
    Grid(tracks: 3) {
      TextCardView(text: "Hello", color: .red)
        .gridItemAlignment(.leading)

      TextCardView(text: "world", color: .blue)
    }
    .gridCommonItemsAlignment(.center)
    .gridContentAlignment(.trailing)
  }
}

struct TextCardView: View {
  let text: String
  let color: UIColor
  var textColor: UIColor = .white

  var body: some View {
    Text(self.text)
      .foregroundColor(Color(self.textColor))
      .padding(5)
      .gridCellBackground { _ in
        ColorView(color)
      }
      .gridCellOverlay { _ in
        RoundedRectangle(cornerRadius: 5)
          .strokeBorder(Color(self.color.darker()),
                        lineWidth: 3)
      }
  }
}

12. 动画

你可以使用 .gridAnimation() 网格修饰符来定义一个特定的动画,该动画将被应用到内部的 ZStack
默认情况下,网格中的每个视图都与其后续索引关联作为其 ID。 因此,SwiftUI 依赖于初始和最终状态下的网格视图位置来执行动画过渡。你可以使用由 Identifiable 模型初始化的 ForEachGridGroup,或者显式 KeyPath 作为 ID,将特定的 ID 与网格视图关联,以强制动画以正确的方式执行。

无法从初始化的 ForEach 视图获取 KeyPath id 值。 其内部内容将在进行动画时通过视图顺序来区分。最好使用带有 Identifiable 模型的 ForEach 或使用显式 ID 值或 Identifiable 模型创建的 GridGroup,以便跟踪网格视图及其在动画中的 View 表示。


13. 缓存

可以缓存 Grid 的生命周期内的网格布局。

仅支持 iOS

网格缓存可以在网格构造函数中指定,也可以使用 .gridCaching(...) 网格修饰符指定。 第一种选择具有更高的优先级。

内存缓存

默认。 缓存是利用 NSCache 实现的。 它将在内存警告通知时清除所有缓存的布局。

无缓存

不使用缓存。 布局计算将在 Grid 生命周期的每一步执行。


14. 条件语句 / @GridBuilder

从 Swift 5.3 开始,我们可以使用自定义函数构建器,而没有任何问题。 这给了我们

@GridBuilder
func headerSegment(flag: Bool) -> some View {
    if flag {
        return GridGroup { ... }
    else {
        return ColorView(.black)
    }
}

发行说明

v1.5.0:
v1.4.2:
之前的版本##### [v1.4.1]( https://github.com/exyte/Grid/releases/tag/1.4.1): - 修复了 Grid 不更新其内容的问题

问题:如果 GridBuilder 中的任何内容项使用任何外部数据,则 Grid 不会更新它。 例如

@State var titleText: String = "title"

Grid(tracks: 2) {
  Text(titleText)
  Text("hello")
}

即使 titleText 发生更改,Grid 也没有更新它。


路线图

许可证

Grid 在 MIT 许可证下可用。 有关更多信息,请参见 LICENSE 文件。

我们的其他开源 SwiftUI 库

PopupView - Toasts 和 popups 库
ScalingHeaderScrollView - 一个带有粘性标题的滚动视图,该标题会在你滚动时缩小
AnimatedTabBar - 带有许多预设动画的标签栏
MediaPicker - 可自定义的媒体选择器
Chat - 聊天 UI 框架,具有完全可自定义的消息单元格、输入视图和内置的媒体选择器
OpenAI - OpenAI REST API 的 Wrapper 库
AnimatedGradient - 动画线性渐变
ConcentricOnboarding - 动画入职流程
FloatingButton - 浮动按钮菜单
ActivityIndicatorView - 多个动画加载指示器
ProgressIndicatorView - 多个动画进度指示器
FlagAndCountryCode - 每个国家的电话代码和标志
SVGView - SVG 解析器
LiquidSwipe - 流体导航动画