LazyVGrid 和 LazyHGrid 对于大多数用例来说已经足够好了,但是 UI/UX 设计师的创造力是无止境的,所以我们总是会受到他们新想法和设计的挑战。今天我将向您展示我们如何使用SwiftUI 布局协议来创建一个新的完全自定义的布局视图,从现在开始我们将其称为Horizo ntalTileGrid。
关于我们要构建的内容:HorizontalTileGrid
我们的Horizo ntalTileGrid 根据块类型数组布局其子视图。当您的 UI 是动态的并且会在运行时更改时(例如,当您调用 API 来检索您需要如何在屏幕上显示、排列和定位视图的信息时),此功能非常有用。但在一切之前,让我向您展示我们将要开发的产品
这是代码(如果您不知道这段代码是如何工作的,请不要担心,但我希望您对我们将要实现的目标有一个鸟瞰图)
/// Defines a list of display types
public let restaurantsLayouts: [HorizontalTileGrid.BlockType] = [
.full,
.double,
.fullCustom(width:300)]
/// Creates a HorizontalTileGrid
HorizontalTileGrid(templates: self.restaurantsLayouts) {
ForEach(restaurants) { food in
RestaurantItemView(food: food)
.padding(1)
}
}
什么是布局协议
SwiftUI 中的Layout 协议定义了一组方法和属性,可用于描述视图及其子视图的布局。这些方法和属性允许我们指定父视图中视图的位置、大小和排列,以及这些视图的对齐、间距和其他与布局相关的属性。
SwiftUI 允许我们使用 Layout 协议为我们的视图构建完全自定义的布局,并且我们的自定义布局可以像 HStack、VStack 或任何其他内置布局类型一样使用。在大多数情况下,我们会使用 SwiftUI 内置的布局容器,例如 HStackLayout 和 VStackLayout、LazyVGrid 和 LazyHGrid 来构建复杂的 UI。但有时我们需要自定义和构建复杂的布局。
自 SwiftUI 诞生以来,我们现在第一次能够直接询问最小、理想或最大视图大小,甚至可以通过使用代理(我们将了解)获得每个视图的布局优先级以及其他很酷的值他们在本文中进一步)
因此,让我们通过创建一个简单的结构并遵循 Layout 协议来开始开发我们的 HorizontalTileGrid
struct HorizontalTileGrid: Layout {
}
很简单,不是吗?但它不会做任何事情,因为我们还没有定制它。然后让我们去实现 Layout 协议的两个最重要的功能:sizeThatFits和placeSubviews
struct HorizontalTileGrid: Layout {
public func HorizontalTileGrid(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
// Not implemented Yet
}
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
// Not implemented Yet
}
}
我将详细说明这两个函数(然后我将讨论这两个函数的所有参数):
大小合身:
在此函数中,我们利用布局对象将提供给我们的所有知识来计算并返回布局的最终尺寸。该sizeThatFits函数有一些有趣的参数,我们将使用这些参数来计算 HorizontalTileGrid 的最终大小。
在 SwiftUI 中,视图选择自己的大小。
所以我们的 HorizontalTileGrid 需要告诉它的父级它的理想尺寸是多少。我们将使用这个函数遍历所有的子视图,并根据结果询问它们的理想尺寸,我们可以计算出我们的 HorizontalTileGrid 的理想尺寸,它可以理想地布局和安排所有的子视图
放置子视图:
我们必须实现的第二个方法是placeSubviews。我们将使用此函数来计算和设置子视图的大小和确切位置。此方法采用与函数相同的参数sizeThatFits,并且还采用一个额外的参数:包含我们的 HorizontalTileGrid 在屏幕上的区域坐标的边界。请记住,视图在 SwiftUI 中选择自己的大小,因此我们的布局容器将通过Proposal参数获得它要求的大小。
在转到代码之前,我需要谈谈 HorizontalTileGrid 中块的类型。我们的 HorizontalTileGrid 视图显示三种类型的块。我们使用这些块来生成我们的模板并计算大小和位置,以便能够将子视图以正确的大小放置在正确的位置。
/// Display types supported by HorizontalTileGrid
///
///
/// ------------- HorizontalTileGrid ---------------------
/// | | D | | D | D |
/// | | 01 | | 01 | 01 |
/// | Block |-----| BlockWithCustomWidth |-----|-----|
/// | | D | | D | D |
/// | | 02 | | 02 | 02 |
/// ------------- HorizontalTileGrid ---------------------
enum BlockType {
/// a block is a square that fills the height of the HorizontalTileGrid (width = height = HorizontalTileGrid.height)
case block
/// a double contains two small slots to hold two views, it divides the height of the HorizontalTileGrid in half and arranges the views horizontally from top to bottom inside those two slots. each slot size would be (width = height = HorizontalTileGrid.height / 4).
case double
/// this is a block but with a custom width. Its height is equal to the HorizontalTileGrid but its width can be various based on the width value.
case blockCustom(width: CGFloat)
}
public struct HorizontalTileGrid: Layout {
....
private let blocks: [BlockType]
/// Initialize the layout with an optional array of block types.
/// - Parameter blocks: an array of block types, in case of the presence of a display type array, the subviews of the layout can be any view, and the number of visible items would be equal to the number of items in the BlockType array. It means that the layout would only display the views that have one representation display type inside the BlockType array.
public init(blocks: [BlockType]) {
self.blocks = blocks
}
...
}
block:一个块是一个正方形,它填充了 HorizontalTileGrid 的高度(width = height = HorizontalTileGrid.height)
double: double 包含两个小插槽来容纳两个视图,它将 HorizontalTileGrid 的高度分成两半,并在这两个插槽内从上到下水平排列视图。每个插槽的大小为 (width = height = HorizontalTileGrid.height / 4)。
blockCustom(width):这是一个块,但具有自定义宽度。它的高度等于 HorizontalTileGrid,但它的宽度可以根据宽度值而变化。
回到布局,我们需要处理的第一件事是sizeThatFits函数。在此函数中,我们需要根据其子视图的大小计算 HorizontalTileGrid 的大小。在 UIKit 中,我们确实可以访问视图的所有属性,例如它的框架、边界和……,而在 SwiftUI 中,我们无法获取这些信息,并且操作视图属性的唯一方法是使用修饰符. 多亏了 Layout 协议,在sizeThatFits中,我们可以访问代表我们的 HorizontalTileGrid 正在排列的视图的Proxy实例。
所以让我们看一下sizeThatFits 函数的参数
public func sizeThatFits ( proposal : ProposedViewSize , subviews : Subviews , cache : inout Cache ) -> CGSize {
```
# 1.建议的ViewSize
在 SwiftUI 中,父视图会为其子视图建议一个大小,这通常是它拥有的整个空间。所以基本上可用的大小将从层次结构的顶部向下提供,并且每个子视图都可以说出他们需要多少空间才能显示其内容。
— — — — How much space do you need? — — — — →
Parent – – – → Child 1 – – – → Child 2 – – – → Child 3
Parent ← – – – Child 1 ← – – – Child 2 ← – – – Child 3
← – – – – – – – – – – CGSize – – – – – – – – – – – –
# 2. 子视图——什么是代理实例?
SwiftUI 中的每个视图都有一个私有代理对象,视图及其代理实例具有将这两者连接在一起的相同标识符。这个代理实例就像是一种访问视图属性的安全方式。我们可以使用代理获取有关子视图的信息,以确定我们需要多少空间来布置 HorizontalTileGrid 中的所有视图。我们不能直接访问视图的代理实例或创建一个新的,在一些罕见的情况下,比如这里的布局,SwiftUI 让我们访问代理实例的集合,我们将使用它们来计算大小或设置视图的位置孩子的意见。
# 3.缓存
我敢打赌你知道为什么我们需要缓存,是的,我们可以存储一些繁重的计算结果,当我们需要这些时,我们不需要重新计算,只需访问存储的值即可。我们缓存的类型可以是任何类型,图像、字符串、类或元组,以及……
让我们写一些代码。
我们需要做的第一件事是知道最小块的大小,也就是这 4 个块中的一个块的大小。

// 1
func sizes(of subviews: Subviews) -> [CGSize] {
subviews
.map({$0.sizeThatFits(.unspecified)})
}
func minimumHeight(of sizes: [CGSize]) -> CGFloat {
return sizes.map({item in max(item.width, item.height)})
.max(by: {$0 < $1}) ?? 1
}
// 3
private func minimumSquareSize(toFit subviews: Subviews) -> CGSize {
let sizes = sizes(of: subviews)
let minHeight = minimumHeight(of: sizes)
return .init(width: minHeight, height: minHeight)
}
1.我编写了一个函数,通过遍历子视图(代理)来收集所有子视图的大小。每个代理都有一个sizeThatFits函数。正如我提到的,在 SwiftUI 中,视图选择自己的大小,但我们可以为我们的子视图建议一个大小,因此它们可能会考虑到它。我没有为子视图建议任何特定大小,因此我可以获得每个子视图的理想大小。
布局容器通常通过提出几种尺寸并查看响应来衡量它们的子视图。容器可以使用此信息来决定如何在其子视图之间分配空间。布局可能会尝试以下特殊建议:
zero建议:视图以其最小尺寸响应。
infinity建议:视图以其最大尺寸响应。
unspecified建议:视图以其理想大小响应。
2.现在我们有了所有的子视图大小,我们可以找到最小的大小,这将是我们的.double 块类型中的一个插槽的大小。我添加了minimumSquareSize函数来计算并返回该插槽大小。
既然我们知道了最小可显示块的大小,就可以计算其他块类型的大小了。让我们来创建一个函数来计算这个吧👇

func standardSquareSize ( from minimumSquareSize : CGSize ) {
return CGSize (width: minimumSquareSize.width 2 , height: minimumSquareSize.height 2 )
}
上面的函数接收最小块大小并据此计算我们的块大小。
现在我们有了最小块大小和完整块大小,我们可以通过缓存它们来提高性能,而不是每次需要这两个大小时都运行所有这些计算。
public func makeCache(subviews: Subviews) -> Cache {
let minimumSquareSize = minimumSquareSize(toFit: subviews)
let standardSquareSize = standardSquareSize(from: minimumSquareSize)
return (standardSquareSize, minimumSquareSize)
}
makeCache函数是 Layout 协议函数之一。缓存函数将在布局初始化时立即调用,并且在sizeThatFits或placeSubviews 中我们可以访问它来读取或修改它。
现在我们拥有了所有必要的工具来开始为sizeThatFit编写代码
/// This is the original sizeThatFits function, but as I mentioned above, we can’t create Proxy instances directly and as we want to be able to test this code, I have added my own custom sizeThatFits function
public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let blocks = self.blocks
return sizeThatFitsBlocks(proposal: proposal, blocks: blocks, cache: &cache)
}
/// Calculates the final size of the HorizontalTileGrid. the height of the layout would be equal to the height of a fullBlock
square which will be calculated here func standardSquareSize(from minimumSquareSize: CGSize) -> CGSize and the minimum required width for showing the child views would be the sum of all block types
func sizeThatFitsBlocks(proposal: ProposedViewSize, blocks: [BlockType], cache: inout Cache) -> CGSize {
let (standardSquareSize, minimumSquareSize) = cache
var isNextABlock: Bool = false
let widthNeeded = blocks.map { tmp in
switch tmp {
case .fullCustom(let width):
isNextABlock = false
return width
case .block:
if isNextABlock {
isNextABlock = false
return 0
}
isNextABlock = true
return minimumSquareSize.height
case .full:
isNextABlock = false
return standardSquareSize.width
}
}
.reduce(0.0, +)
return CGSize(width: widthNeeded, height: standardSquareSize.height)
}
基于块,我计算了 HorizontalTileGrid 的大小,我想提的一件重要的事情是我创建了两个函数,一个是原始的 Layout 函数,另一个是我自己的函数。如您所见,我在自己的函数中进行了所有计算,原因是我们需要能够测试我们的代码。正如我所指出的,代理对象的初始化程序是私有的,我们不能直接创建代理,因此,我创建了sizeThatFits函数的重载,以便我可以对自己的函数进行单元测试。
现在我们知道我们需要多少空间才能显示所有子视图,是时候计算每个子视图的大小和位置,并根据它们表示的块类型将每个子视图放置在正确的位置。
# 放置子视图
Layout Protocol 的placeSubviews函数是我们计算每个子视图的位置和大小并使用其代理实例来设置其位置和框架大小的地方。placeSubviews函数的参数类似于sizeThatFits除了一个,边界。我将跳过所有其他参数的解释。
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
///
}
Bounds:布局本身的位置。这个变量告诉我们 HorizontalTileGrid 在屏幕上的定位。我们将使用诸如 minX、minY 或……之类的属性来了解我们布局的左上角点是什么,并将根据该点放置和定位我们的子视图。
func calculatePlaceSubviews(in bounds: CGRect, proposal: ProposedViewSize, blocks: [BlockType], cache: inout Cache) -> [CGRect] {
var calculatedPlaces: [CGRect] = []
let (standardSquareSize, minimumSquareSize) = cache
var traversedX = bounds.minX
var nextCellInDoubledColumnPosition: CGPoint? = nil
blocks.indices.forEach { blockIndex in
let point: CGPoint
let size: CGSize
switch blocks[blockIndex] {
case .block:
size = CGSize(width: minimumSquareSize.width, height: minimumSquareSize.height)
if let nextSlotPoint = nextCellInDoubledColumnPosition {
point = nextSlotPoint
nextCellInDoubledColumnPosition = nil
} else {
point = CGPoint(x: traversedX + minimumSquareSize.width.half, y: bounds.minY + minimumSquareSize.height.half)
nextCellInDoubledColumnPosition = point
nextCellInDoubledColumnPosition!.y = point.y + minimumSquareSize.height
traversedX += minimumSquareSize.width
}
case .fullCustom(let width):
nextCellInDoubledColumnPosition = nil
size = CGSize(width: width, height: standardSquareSize.height)
point = CGPoint(x: traversedX + width.half, y: bounds.midY)
traversedX += width
case .full:
nextCellInDoubledColumnPosition = nil
size = CGSize(width: standardSquareSize.width, height: standardSquareSize.height)
point = CGPoint(x: traversedX + standardSquareSize.width.half, y: bounds.minY + standardSquareSize.height.half)
traversedX += standardSquareSize.width
}
calculatedPlaces.append(CGRect(origin: point, size: size))
}
return calculatedPlaces
}
/// Calculates and manages the position of each subview inside the layout view
/// – Parameters:
/// – bounds: the bounds of the layout view
/// – proposal: the proposed size of the layout view which is the one that we returns from this function public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize
/// – subviews: list of all subviews
/// – cache: cache
/// – Returns: returns a list of the positions. Each item in this list represents the position of one subview in the layout view
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let blocks = self.blocks
let calculated = calculatePlaceSubviews(in: bounds, proposal: proposal, blocks: blocks, cache: &cache)
zip(subviews, calculated).forEach { view, proposedSizePosition in
view.place(at: proposedSizePosition.origin, anchor: .center, proposal: ProposedViewSize(width: proposedSizePosition.size.width, height: proposedSizePosition.size.height))
}
}
“`
如您所见,我创建了一个calculatePlaceSubviews函数,在该函数中,我遍历了块类型并计算了每个子视图的确切位置和大小,最后,我返回了所有这些Rects (origin, size)。然后我在 placeSubviews 函数中抓取了所有这些Rect,我遍历了所有子视图并使用 place 函数设置该视图的中心(锚点:.center)及其大小。
view.place(at: proposedSizePosition.origin,
anchor: .center,
proposal: ProposedViewSize(width: proposedSizePosition.size.width,
height: proposedSizePosition.size.height))
现在一切都准备好使用我们的 HorizontalTileGrid,让我们使用它。我在这个存储库中上传了完整的实现
ScrollView(.horizontal) {
HorizontalTileGrid(blocks: self.restaurantsLayout) {
ForEach(restaurants) { food in
RestaurantItemView(food: food)
.padding(1)
}
}
}
.scrollIndicators(.hidden)
感谢您阅读这篇文章,它演示了我们如何使用 SwiftUI 中的新布局协议来开发自定义网格视图。
项目地址
https://github.com/farshadjahanmanesh/HorizontalTileGrid