Lab 072 – More fun with HoneycombLayout

Using rotation3DLayout, adjust angle, and animating some changes.

This lab builds on the HoneycombLayout that we started in Lab 067. For the most part, I’m just playing with some animations to change the size, angle, and rotation of the layout. One notable change is that this version will do a better job of expanding the HoneycombLayout content to fill the available space.

This custom layout always adds new elements start from the center. It might be fun to work on a version that works more like a hex grid that can draw from an outer corner. For example, Top Leading or Bottom Trailing.

Video Demo:

Lab Code

Lab 072 Usage

HoneycombLayout(angleOffset: angleOffset, hexSize: hexSize) {
    ForEach(0..<nodes, id: \.self) { index in
        ModelViewEmoji(
            name: "UISphere01",
            hexSize: hexSize, emoji: emoji[index],
            bundle: realityKitContentBundle
        )
        .rotation3DLayout(Rotation3D(angle: .degrees(360 - layoutRotation), axis: .x))
    }
}

Revised HoneycombLayout

fileprivate struct HoneycombLayout: Layout, Animatable {
    var angleOffset: Angle = .zero
    var hexSize: CGFloat = 60

    // Calculate the number of rings needed for a given number of items
    private func calculateRings(for itemCount: Int) -> Int {
        if itemCount <= 1 { return 1 }
        if itemCount <= 7 { return 2 }
        if itemCount <= 19 { return 3 }
        if itemCount <= 37 { return 4 }
        if itemCount <= 61 { return 5 }
        return 6 // Default for larger counts
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let updatedProposal = proposal.replacingUnspecifiedDimensions()
        let minDim = min(updatedProposal.width, updatedProposal.height)
        return CGSize(width: minDim, height: minDim)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard !subviews.isEmpty else { return }

        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        
        // Calculate dynamic hex size based on available space and number of items
        let minDimension = min(bounds.width, bounds.height)
        let rings = calculateRings(for: subviews.count)
        let availableRadius = minDimension / 2 - 20 // Leave 20pt padding
        let dynamicHexSize = min(hexSize, availableRadius / CGFloat(rings))
        
        // Calculate hexagon spacing to fill the available space
        let hexSpacing = availableRadius / CGFloat(rings)

        // Generate hexagon positions in a spiral pattern
        var positions: [CGPoint] = []
        positions.append(center) // Center hexagon

        var ring = 1
        while positions.count < subviews.count {
            // For each ring, place hexagons at 60-degree intervals
            for i in 0..<6 {
                let angle = Double(i) * .pi / 3 + angleOffset.radians
                let radius = Double(ring) * hexSpacing
                let x = center.x + radius * cos(angle)
                let y = center.y + radius * sin(angle)
                positions.append(CGPoint(x: x, y: y))
            }

            // Fill in the gaps between the corners for larger rings
            if ring > 1 {
                for i in 0..<6 {
                    let startAngle = Double(i) * .pi / 3 + angleOffset.radians
                    let endAngle = Double(i + 1) * .pi / 3 + angleOffset.radians
                    let radius = Double(ring) * hexSpacing

                    // Add intermediate positions with adjusted radius for tighter honeycomb
                    for j in 1..<ring {
                        let angle = startAngle + (endAngle - startAngle) * Double(j) / Double(ring)

                        // Adjust radius for items that should be closer to center
                        // Items at the edges of each segment get pulled in slightly
                        let radiusAdjustment = 0.15 // Pull items in by 15%
                        let adjustedRadius = radius * (1.0 - radiusAdjustment)

                        let x = center.x + adjustedRadius * cos(angle)
                        let y = center.y + adjustedRadius * sin(angle)
                        positions.append(CGPoint(x: x, y: y))
                    }
                }
            }

            ring += 1
        }

        // Place subviews at calculated positions
        for (index, subview) in subviews.enumerated() {
            if index < positions.count {
                subview.place(
                    at: positions[index],
                    anchor: .center,
                    proposal: .init(width: dynamicHexSize, height: dynamicHexSize)
                )
            }
        }
    }

    var animatableData: Angle.AnimatableData {
        get { angleOffset.animatableData }
        set { angleOffset.animatableData = newValue }
    }
}

Support our work so we can continue to bring you new examples and articles.

Questions or feedback?