Add LazyGridLayout

This commit is contained in:
Carson Katri 2022-07-06 17:16:39 -04:00
parent 77fd4a6b55
commit b5e1d991d2
5 changed files with 284 additions and 9 deletions

View File

@ -0,0 +1,273 @@
// Copyright 2022 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 7/6/22.
//
import Foundation
@_spi(TokamakCore)
public struct _LazyGridLayoutCache {
var resolvedItems = [ResolvedItem]()
var mainAxisSizes = [CGFloat]()
struct ResolvedItem {
let size: CGFloat
let item: GridItem
}
}
@_spi(TokamakCore)
public protocol _LazyGridLayout: Layout where Cache == _LazyGridLayoutCache {
static var axis: Axis { get }
var items: [GridItem] { get }
var _alignment: Alignment { get }
var spacing: CGFloat? { get }
}
public extension _LazyGridLayout {
internal var mainAxis: WritableKeyPath<CGSize, CGFloat> {
switch Self.axis {
case .horizontal:
return \.width
case .vertical:
return \.height
}
}
internal var crossAxis: WritableKeyPath<CGSize, CGFloat> {
switch Self.axis {
case .horizontal:
return \.height
case .vertical:
return \.width
}
}
func makeCache(subviews: Subviews) -> Cache {
.init()
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
cache.resolvedItems.removeAll()
var reservedFixedSpace = CGFloat.zero
for (index, item) in items.enumerated() {
if case .adaptive = item.size {
continue
}
if index < items.count - 1 {
reservedFixedSpace += item.spacing ?? 8
}
if case let .fixed(fixed) = item.size {
reservedFixedSpace += fixed
}
}
let proposal = proposal.replacingUnspecifiedDimensions()
var remainingSize = proposal[keyPath: crossAxis] - reservedFixedSpace
let flexibleItems = items.filter {
if case .fixed = $0.size {
return false
} else {
return true
}
}
var remainingItems = flexibleItems.count
for item in items {
switch item.size {
case let .flexible(minimum, maximum):
let divviedSpace = remainingSize / CGFloat(remainingItems)
let size = max(minimum, min(maximum, divviedSpace))
remainingSize -= size
cache.resolvedItems.append(.init(size: size, item: item))
remainingItems -= 1
case let .fixed(size):
cache.resolvedItems.append(.init(size: size, item: item))
case let .adaptive(minimum, maximum):
let divviedSpace = remainingSize / CGFloat(remainingItems)
var remaining = divviedSpace
var fitCount = 0
while true {
if fitCount != 0 {
remaining -= item.spacing ?? 8
}
if remaining - minimum < 0 {
break
}
remaining -= minimum
fitCount += 1
}
let fitSize = min(
max(
(divviedSpace - ((item.spacing ?? 8) * CGFloat(fitCount - 1))) / CGFloat(fitCount),
minimum
),
maximum
)
for _ in 0..<fitCount {
remainingSize -= fitSize
cache.resolvedItems.append(.init(size: fitSize, item: item))
}
remainingItems -= 1
}
}
var mainAxisSize = CGFloat.zero
var maxMainAxisSize = CGFloat.zero
var mainAxisSpacing = CGFloat.zero
for (index, subview) in subviews.enumerated() {
let itemIndex = index % cache.resolvedItems.count
let itemSize = cache.resolvedItems[itemIndex].size
let size = subview.sizeThatFits(.init(
width: Self.axis == .vertical ? itemSize : nil,
height: Self.axis == .horizontal ? itemSize : nil
))
if size[keyPath: mainAxis] > maxMainAxisSize {
maxMainAxisSize = size[keyPath: mainAxis]
}
if subviews.indices.contains(index + cache.resolvedItems.count) {
let spacing = subview.spacing.distance(
to: subviews[index + cache.resolvedItems.count].spacing,
along: .vertical
)
if spacing > mainAxisSpacing {
mainAxisSpacing = spacing
}
}
if itemIndex == cache.resolvedItems.count - 1 {
cache.mainAxisSizes.append(maxMainAxisSize)
mainAxisSize += maxMainAxisSize + mainAxisSpacing
maxMainAxisSize = .zero
mainAxisSpacing = .zero
}
}
cache.mainAxisSizes.append(maxMainAxisSize)
mainAxisSize += maxMainAxisSize
var result = proposal
result[keyPath: mainAxis] = mainAxisSize
return result
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
let contentSize = cache.resolvedItems.enumerated().reduce(into: .zero) {
$0 += $1.element
.size + ($1.offset == cache.resolvedItems.count - 1 ? 0 : $1.element.item.spacing ?? 8)
}
var offset = CGSize.zero
let origin = CGSize(width: bounds.width, height: bounds.height)
let contentAlignmentID: AlignmentID.Type
switch Self.axis {
case .horizontal:
contentAlignmentID = _alignment.vertical.id
case .vertical:
contentAlignmentID = _alignment.horizontal.id
}
let startOffset = contentAlignmentID.defaultValue(
in: .init(size: origin, alignmentGuides: [:])
) - contentAlignmentID.defaultValue(
in: .init(size: .init(width: contentSize, height: contentSize), alignmentGuides: [:])
)
offset[keyPath: crossAxis] = startOffset
var mainAxisSpacing = CGFloat.zero
for (index, subview) in subviews.enumerated() {
let itemIndex = index % cache.resolvedItems.count
let mainAxisIndex = index / cache.resolvedItems.count
if itemIndex == 0 {
offset[keyPath: crossAxis] = startOffset
mainAxisSpacing = .zero
}
var proposal = CGSize.zero
proposal[keyPath: mainAxis] = cache.mainAxisSizes[mainAxisIndex]
proposal[keyPath: crossAxis] = cache.resolvedItems[itemIndex].size
let dimensions = subview.dimensions(in: .init(proposal))
var position = offset
position.width += cache.resolvedItems[itemIndex].item.alignment.horizontal.id
.defaultValue(in: .init(
size: proposal,
alignmentGuides: [:]
))
position.height += cache.resolvedItems[itemIndex].item.alignment.vertical.id
.defaultValue(in: .init(
size: proposal,
alignmentGuides: [:]
))
position.width -= dimensions[
cache.resolvedItems[itemIndex].item.alignment.horizontal
]
position.height -= dimensions[
cache.resolvedItems[itemIndex].item.alignment.vertical
]
subview.place(
at: .init(
x: bounds.minX + position.width,
y: bounds.minY + position.height
),
proposal: .init(proposal)
)
offset[keyPath: crossAxis] += cache.resolvedItems[itemIndex].size
offset[keyPath: crossAxis] += cache.resolvedItems[itemIndex].item.spacing ?? 8
if spacing == nil && subviews.indices.contains(index + cache.resolvedItems.count) {
let spacing = subview.spacing.distance(
to: subviews[index + cache.resolvedItems.count].spacing,
along: Self.axis
)
if spacing > mainAxisSpacing {
mainAxisSpacing = spacing
}
}
if itemIndex == cache.resolvedItems.count - 1 {
offset[keyPath: mainAxis] += cache.mainAxisSizes[mainAxisIndex] + (
spacing ?? mainAxisSpacing
)
}
}
}
}
@_spi(TokamakCore)
extension LazyVGrid: _LazyGridLayout {
public static var axis: Axis { .vertical }
public var items: [GridItem] { columns }
public var _alignment: Alignment { .init(horizontal: alignment, vertical: .center) }
}
@_spi(TokamakCore)
extension LazyHGrid: _LazyGridLayout {
public static var axis: Axis { .horizontal }
public var items: [GridItem] { rows }
public var _alignment: Alignment { .init(horizontal: .center, vertical: alignment) }
}

View File

@ -25,7 +25,7 @@ public struct GridItem {
}
public var size: GridItem.Size
public var spacing: CGFloat
public var spacing: CGFloat?
public var alignment: Alignment
public init(
@ -34,7 +34,7 @@ public struct GridItem {
alignment: Alignment? = nil
) {
self.size = size
self.spacing = spacing ?? 4
self.spacing = spacing
self.alignment = alignment ?? .center
}
}

View File

@ -20,7 +20,8 @@ import Foundation
public struct LazyHGrid<Content>: _PrimitiveView where Content: View {
let rows: [GridItem]
let alignment: VerticalAlignment
let spacing: CGFloat
@_spi(TokamakCore)
public let spacing: CGFloat?
let pinnedViews: PinnedScrollableViews
let content: Content
@ -33,7 +34,7 @@ public struct LazyHGrid<Content>: _PrimitiveView where Content: View {
) {
self.rows = rows
self.alignment = alignment
self.spacing = spacing ?? 8
self.spacing = spacing
self.pinnedViews = pinnedViews
self.content = content()
}
@ -46,5 +47,5 @@ public struct _LazyHGridProxy<Content> where Content: View {
public var rows: [GridItem] { subject.rows }
public var content: Content { subject.content }
public var spacing: CGFloat { subject.spacing }
public var spacing: CGFloat { subject.spacing ?? 8 }
}

View File

@ -20,7 +20,8 @@ import Foundation
public struct LazyVGrid<Content>: _PrimitiveView where Content: View {
let columns: [GridItem]
let alignment: HorizontalAlignment
let spacing: CGFloat
@_spi(TokamakCore)
public let spacing: CGFloat?
let pinnedViews: PinnedScrollableViews
let content: Content
@ -33,7 +34,7 @@ public struct LazyVGrid<Content>: _PrimitiveView where Content: View {
) {
self.columns = columns
self.alignment = alignment
self.spacing = spacing ?? 8
self.spacing = spacing
self.pinnedViews = pinnedViews
self.content = content()
}
@ -46,5 +47,5 @@ public struct _LazyVGridProxy<Content> where Content: View {
public var columns: [GridItem] { subject.columns }
public var content: Content { subject.content }
public var spacing: CGFloat { subject.spacing }
public var spacing: CGFloat? { subject.spacing }
}

View File

@ -53,7 +53,7 @@ extension LazyVGrid: _HTMLPrimitive {
styles += "justify-items: \(lastCol.alignment.horizontal.cssValue);"
styles += "align-items: \(lastCol.alignment.vertical.cssValue);"
}
styles += "grid-gap: \(_LazyVGridProxy(self).spacing)px;"
styles += "grid-gap: \(_LazyVGridProxy(self).spacing ?? 8)px;"
return styles
}