MyGrid
Last updated
Was this helpful?
Last updated
Was this helpful?
⭐️ 注意:👔 MyGrid
<
Item, ItemView
>
是一個 generics & subtypes❗️
使用 generic type 的 nested type 要小心❗️
👔 MyGrid (line: 44):在 MyGrid 內部使用 Layout 基本上沒什麼問題
👁️ TestMyGrid (line: 52):在 MyGrid 外面使用 Layout 就要小心了,如果該行寫成下面這樣:
let layout = MyGrid.Layout( ... )
那就會出現下列問題:
本例由 Grid 改編而來。
similar to grids layout.
MyGrid.Layout
is nested types in generics & subtypes.
code could cause ⛔️ Error: Generic parameter '...' could not be inferred.
to nest or not to nest❓ - question about nested types.
revised to show the problem with .readSize(). 🐞
⬆️ 需要: arr.index(of:)
// 2022.02.15
import SwiftUI
/// 👔 MyGrid<Item, ItemView>
/// ```
/// MyGrid(items: items) { item in
/// /* build item view */
/// }
/// ```
struct MyGrid<Item: Identifiable, ItemView: View>: View {
// 1. 使用者要提供:
// • items : 排版的項目
// • cellAspectRatio: 希望的格子比例 (default = 1)
// • viewForItem : 繪製項目視圖的方法 (@ViewBuilder closure)
let items: [Item]
var cellAspectRatio: CGFloat = 1
@ViewBuilder let viewForItem: (Item) -> ItemView
// 2. 當 MyGrid 開始排版時:
public var body: some View {
// ⭐️ 2.1 MyGrid 會利用 GeometryReader 得到 proposed size,
GeometryReader { geo in
// ⭐️ 2.2 然後用 `self.itemViews(in: size)` 繪製所有項目的視圖。
self.itemViews(in: geo.size)
}
}
}
extension MyGrid {
// 2.2
@ViewBuilder func itemViews(in size: CGSize) -> some View {
// ⭐️ 2.2.1: 委託 MyGrid.Layout 計算「最佳的排版方式」(layout)。
//
// layout 擁有以下資訊:
// • .rows, .cols:「該切成幾行幾列」
// • .cellSize :「格子比例」
// • .centerOfCell(at: i):「第幾個項目應該排在哪個位置」
//
// 👔 MyGrid<Item, ItemView>.Layout
let layout = Layout(
cellAspectRatio, count: items.count, in: size
)
// ⭐️ ForEach
ForEach(items) { item in
// ⭐️ 2.2.2: 委託 `self.view(for: item, in: layout)` 繪製每個項目的視圖。
self.view(for: item, in: layout)
}
}
// 2.2.2
@ViewBuilder func view(for item: Item, in layout: Layout) -> some View
{
// ⭐️ 2.2.2.1: 計算項目編號,得知它是第幾個項目。
let i = index(of: item)
// ⭐️ 2.2.2.2: 計算項目的中心位置
let center = layout.centerOfCell(at: i) // 👔 MyGrid<Item, ItemView>.Layout
// ⭐️ 2.2.2.3: 繪製項目視圖,並放在該放的位置。
viewForItem(item)
.frame(layout.cellSize) // ⭐️ cell size
// -------------------------------------------
// ⚠️ 注意:
// 一旦加上 .position 之後,.frame 會變成整個
// GeometryReader 的大小,不再是單一卡片的大小。
// -------------------------------------------
.position(center) // ⭐️ put item view in position
}
/// 2.2.2.1: index of item
func index(of item: Item) -> Int {
items.index(of: item)! // 🌀Array + .index(of:)
} // where Element: Identifiable
}
⬆️ 需要: Vector2D, floating +−⨉÷ int
// 2022.02.15
import SwiftUI
extension MyGrid {
/// 👔 MyGrid<Item, ItemView>.Layout
struct Layout {
// ⭐ proposed size
let size: CGSize
// ⭐ best layout in rows x cols
// (to be calculated in `init`, with all available space taken)
private(set) var rows: Int = 0
private(set) var cols: Int = 0
// ⭐ init
// 根據項目的數量(n)、所給的 proposed size(size)、與希望的 cell view 比例(r),
// 計算最佳的列數(rows)、行數(cols),然後存回 GridLayout 的屬性。
// 如果指定的行列數所切割出來的格子,其「「長寬比」最接近使用者指定的
// idealCellAspectRatio,則為最佳排版方式。
/// ```
/// GridLayout(itemCount: n, in: size, cellAspectRatio: r)
/// ```
public init(
_ idealCellAspectRatio: CGFloat = 1, // ideal cell aspect ratio
count n: Int, // item count
in size: CGSize // proposed size
) {
self.size = size
// if zero width or height or itemCount is not > 0,
// do nothing.
guard
size.width > 0, size.height > 0, n > 0
else { return }
// ⭐ find the bestLayout
// -----------------------
// which results in cells whose `aspectRatio`
// has the `smallestVariance` from `idealAspectRatio`
var bestLayout: (rows: Int, cols: Int) = (1, n) // start from 1 row
var smallestVariance: CGFloat?
// start to find best layout from 1 column
for cols in 1...n {
// ⭐ calculate how many columns we need
let rows = (n / cols) + (n % cols > 0 ? 1 : 0)
// cellAspectRatio = (cell width) / (cell height)
// = (width/cols) / (height/rows)
// = (width/height) / (cols/rows)
// = size.aspectRatio * rows/cols
// -----------------------------------------------------------
// ⭐️ rows: ↘ (decreasing)
// cols: ↗︎ (strictly increasing)
// ⇒ cellAspectRatio: ↘ (strictly decreasing)
// -----------------------------------------------------------
// 🅿️ Vector2D (🌀CGSize + .aspectRatio)
// 🌀CGFloat (*) | (/) Int: custom operator
let cellAspectRatio = size.aspectRatio * rows / cols
// current variance with ideal aspect ratio
let variance = self.variance(cellAspectRatio, idealCellAspectRatio)
// ⭐️ if `smallestVariance` not set, or `variance` getting smaller,
// set new `smallestVariance` and new bestLayout
if smallestVariance == nil || variance < smallestVariance! {
smallestVariance = variance
bestLayout = (rows: rows, cols: cols)
} else {
// ⭐️ `smallVariance` is set, and `variance` is getting larger,
// break for loop
break
}
}
// save best layout found
self.rows = bestLayout.rows
self.cols = bestLayout.cols
}
}
}
extension MyGrid.Layout {
/// ⭐️ cell size
public var cellSize: CGSize {
(rows == 0 || cols == 0)
? .zero
// 🅿️ Vector2D (🌀CGSize(w,h)), CGFloat / Int
: CGSize(size.width / cols, size.height / rows)
}
/// ⭐️ center point of cell at index `i`
public func centerOfCell(at i: Int) -> CGPoint {
let rowIndex = i / cols
let colIndex = i % cols
return (rows == 0 || cols == 0)
? .zero
// 🅿️ Vector2D, CGFloat + Int
: cellSize.point ** [0.5 + colIndex, 0.5 + rowIndex]
}
/// ⭐️ variance between two aspect ratios
func variance(_ r1: CGFloat, _ r2: CGFloat) -> CGFloat {
precondition(r1 > 0 && r2 > 0)
// ⭐️ 做對數計算,才不會造成 1:3 = 0.33 的比例
// 比 2:1 = 2 的比例更接近 1:1 = 1
return abs(log(r1) - log(r2))
}
}
⬆️ 需要: SlidersForSize
// 2022.02.15
import SwiftUI
struct TestMyGrid: View {
@State private var size = CGSize(300, 200) // proposed size
@State private var ratio: CGFloat = 1 // current cell aspect ratio
@State private var rows = 0 // current # of rows
@State private var cols = 0 // current # of cols
let items = Array(1...10)
var idealCellAspectRatio: CGFloat = 1
var body: some View {
VStack {
ScrollView {
myGrid.padding(40)
}
controls
}
}
}
extension TestMyGrid {
var myGrid: some View {
// 👔 MyGrid<Item, ItemView>
MyGrid(
items: items, // ⭐️ Item == Int (require: Int: Identifiable)
cellAspectRatio: idealCellAspectRatio
)
{ i in // ⭐️ viewForItem: (Item) -> ItemView
Color.purple
.border(.black)
.overlay { Text("\(i)").bold().shadow(radius: 3) }
}
// ⭐️ read proposed size from parent (.frame() modifier)
.readSize { size in
// calc current layout
// --------------------------------------------------------------------
// ⭐️ 問題:
// 這裏如果寫成:`let layout = MyGrid.Layout( ... )`,會產生錯誤:
// ⛔ Error: Generic parameter 'Item' could not be inferred.
// ⛔ Error: Generic parameter 'ItemView' could not be inferred.
// ⭐️ 解決方案:
// "Explicitly specify the generic arguments to fix this issue."
// ⭐️ 解藥:
// 還好 Layout 的運作方式與 Item, ItemView 的型別無關,所以任意指定即可,
// 例如:MyGrid<Int, Text>.Layout
// --------------------------------------------------------------------
let layout = MyGrid<Int, Text>.Layout(
idealCellAspectRatio, count: items.count, in: size
)
rows = layout.rows
cols = layout.cols
ratio = layout.cellSize.aspectRatio
}
// ⭐️ parent's proposed size
.frame(size)
.dimension(.topLeading, arrow: .blue, label: .orange)
.shadowedBorder()
.padding(.bottom, 40)
}
/// view controls
var controls: some View {
VStack {
Text("Cells try to maintain their aspect ratio.")
.font(.title3)
Group {
Text("ideal cell aspect ratio = \(idealCellAspectRatio.decimalPlaces(2))")
Text("current cell aspect ratio = \(ratio.decimalPlaces(2))")
Text("rows: \(rows), cols: \(cols)")
}
.font(.caption).foregroundColor(.secondary)
SlidersForSize($size)
}
}
}
struct TestMyGrid_Previews: PreviewProvider {
static var previews: some View {
TestMyGrid(idealCellAspectRatio: 4/3)
}
}