Skip to content

Instantly share code, notes, and snippets.

@swiftui-lab
Last active October 2, 2024 14:49
Show Gist options
  • Save swiftui-lab/5da9264f955354e4ea2261c458d29a4e to your computer and use it in GitHub Desktop.
Save swiftui-lab/5da9264f955354e4ea2261c458d29a4e to your computer and use it in GitHub Desktop.
A demonstration of a layout that interpolates the result of two other layouts.
// Author: SwiftUI-Lab (swiftui-lab.com)
// Description: A demonstration of a layout that interpolates
// the result of two other layouts.
// blog article: https://swiftui-lab.com/layout-protocol-part-2
import SwiftUI
// ------------------------------------------------------------------
// Views
// ------------------------------------------------------------------
struct ContentView: View {
let colors: [Color] = [.yellow, .orange, .red, .pink, .purple, .blue, .cyan, .green]
@State var amplitude: CGFloat = 20.0
@State var frequency: CGFloat = 2.0
@State var phase: Angle = .degrees(0)
@State var radius: CGFloat = 100
@State var rotation: Angle = .zero
@State var pct: Double = 1
var body: some View {
VStack {
Spacer()
InterpolatedLayout(pct: pct,
wheelRadius: radius,
wheelRotation: rotation,
waveAmplitude: amplitude,
waveFrequency: frequency,
wavePhase: phase) {
ForEach(0..<24) { idx in
ContainerComponent {
RoundedRectangle(cornerRadius: 8)
.fill(colors[idx%colors.count].opacity(0.7))
.frame(width: 30, height: 30)
.overlay { Text("\(idx+1)") }
}
}
}
.onAppear {
withAnimation(.linear(duration: 4.0).repeatForever(autoreverses: false)) {
rotation = .degrees(360)
phase = phase + .degrees(360)
}
}
Spacer()
Text(String(format: "pct = %.2f", pct))
Slider(value: $pct, in: 0...1)
.frame(width: 200)
.padding(.bottom, 20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white)
}
}
struct ContainerComponent<V: View>: View {
var animation: Animation? = nil
@ViewBuilder let content: () -> V
@State private var rotation: Angle = .zero
var body: some View {
content()
.rotationEffect(rotation)
.layoutValue(key: Rotation.self, value: $rotation.animation(animation))
}
}
// ------------------------------------------------------------------
// Auxiliary types
// ------------------------------------------------------------------
// A layout value used to rotate each view to point to the center of the wheel, or
// be tangential to the sine wave
struct Rotation: LayoutValueKey {
static let defaultValue: Binding<Angle>? = nil
}
// A struct used to report computed positions and rotations of views,
// from the sub-layouts (wheel and wave), to the interpolated layout.
struct SharedCache {
var standalone: Bool // if false, the layout is being used by InterpolatedLayout
var viewPositions: [CGPoint]
var viewRotations: [Angle]
}
// ------------------------------------------------------------------
// Layout types
struct WaveLayout: Layout {
let spacing: CGFloat
var amplitude: CGFloat
var frequency: CGFloat
var phase: Angle
var animatableData: AnimatablePair<AnimatablePair<CGFloat,CGFloat>, Double> {
get {
AnimatablePair(AnimatablePair(amplitude, frequency), phase.radians)
}
set {
amplitude = newValue.first.first
frequency = newValue.first.second
phase = Angle.radians(newValue.second)
}
}
func makeCache(subviews: Subviews) -> SharedCache {
SharedCache(standalone: true,
viewPositions: Array<CGPoint>(repeating: .zero, count: subviews.count),
viewRotations: Array<Angle>(repeating: .zero, count: subviews.count))
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout SharedCache) -> CGSize {
// Compute pt used for spacing, the tallest view and the total width used by views
let (accSpace, maxHeight, accWidth) = computeValues(subviews: subviews)
return CGSize(width: accSpace + accWidth, height: maxHeight + 2 * amplitude)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout SharedCache) {
// Compute pt used for spacing and the total width used by views
let (accSpace, _, accWidth) = computeValues(subviews: subviews)
// Computer the container's total width
let containerWidth = accSpace + accWidth
// Initialized subviews anchor point
var pt = CGPoint(x: bounds.minX, y: bounds.midY)
for (index, v) in subviews.enumerated() {
let viewSize = v.sizeThatFits(.unspecified)
// The sinusoid is: y = sin(x) where x is an angle. For example, if
// frequency = 2 and phase = 180°, then x would go from 180° to 900° (i.e., 180 + (2 x 360°))
let sinusoid_x = Angle.degrees(frequency * 360) * ((pt.x - bounds.minX) / containerWidth) + phase
let sinusoid_y = sin(sinusoid_x.radians)
pt.y = bounds.midY + amplitude * sinusoid_y
v.place(at: pt, anchor: .leading, proposal: .unspecified)
// Calculate view's rotation: The angle of the tangent of a curve,
// is calculated as the arctangent of the derivative of the function
let viewRotation = atan((((Angle.degrees(frequency * 360) * cos(sinusoid_x.radians))/containerWidth) * amplitude).radians)
if cache.standalone {
// Set rotation in layout value binding
DispatchQueue.main.async {
v[Rotation.self]?.wrappedValue = .radians(viewRotation)
}
} else {
// Communicate position and rotations to the parent layout (InterpolatedLayout)
cache.viewPositions[index] = CGPoint(x: pt.x + v.sizeThatFits(.unspecified).width/2, y: pt.y)
cache.viewRotations[index] = .radians(viewRotation)
}
// Advanced to next subview
pt.x += viewSize.width + spacing
}
}
// Helper method
func computeValues(subviews: Subviews) -> (accSpace: CGFloat, maxHeight: CGFloat, width: CGFloat) {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxHeight = sizes.reduce(0) { max($0, $1.height) }
let accWidth = sizes.reduce(0) { $0 + $1.width }
let accSpace = spacing * CGFloat(subviews.count - 1)
return (accSpace, maxHeight, accWidth)
}
}
struct WheelLayout: Layout {
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(rotation.radians, radius)
}
set {
rotation = Angle.radians(newValue.first)
radius = newValue.second
}
}
var radius: CGFloat
var rotation: Angle
var pointToCenter = false
func makeCache(subviews: Subviews) -> SharedCache {
SharedCache(standalone: true,
viewPositions: Array<CGPoint>(repeating: .zero, count: subviews.count),
viewRotations: Array<Angle>(repeating: .zero, count: subviews.count))
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout SharedCache) -> CGSize {
let maxSize = subviews.map { $0.sizeThatFits(proposal) }.reduce(CGSize.zero) {
return CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height))
}
return CGSize(width: (maxSize.width / 2 + radius) * 2,
height: (maxSize.height / 2 + radius) * 2)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout SharedCache) {
let angleStep = (Angle.degrees(360).radians / Double(subviews.count))
for (index, subview) in subviews.enumerated() {
let angle = angleStep * CGFloat(index) + rotation.radians
// Find a vector with an appropriate size and rotation.
var point = CGPoint(x: 0, y: -radius).applying(CGAffineTransform(rotationAngle: angle))
// Shift the vector to the middle of the region.
point.x += bounds.midX
point.y += bounds.midY
// Place the subview.
subview.place(at: point, anchor: .center, proposal: .unspecified)
if cache.standalone {
// Set rotation in layout value binding
DispatchQueue.main.async {
if pointToCenter {
subview[Rotation.self]?.wrappedValue = .radians(angle)
} else {
subview[Rotation.self]?.wrappedValue = .zero
}
}
} else {
// Communicate position and rotations to the parent layout (InterpolatedLayout)
cache.viewPositions[index] = point
if pointToCenter {
cache.viewRotations[index] = .radians(angle)
} else {
cache.viewRotations[index] = .zero
}
}
}
}
}
struct InterpolatedLayout: Layout {
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, Double>>> {
get {
AnimatablePair(AnimatablePair(wheelRadius, wheelRotation.radians),
AnimatablePair(AnimatablePair(waveAmplitude, waveFrequency), AnimatablePair(wavePhase.radians, pct)))
}
set {
wheelRadius = newValue.first.first
wheelRotation = .radians(newValue.first.second)
waveAmplitude = newValue.second.first.first
waveFrequency = newValue.second.first.second
wavePhase = .radians(newValue.second.second.first)
pct = newValue.second.second.second
}
}
var pct: CGFloat
var wheelRadius: CGFloat
var wheelRotation: Angle
var waveAmplitude: CGFloat
var waveFrequency: CGFloat
var wavePhase: Angle
struct CombinedCache {
var wheel: WheelLayout
var wave: WaveLayout
var common: SharedCache
}
func makeCache(subviews: Subviews) -> CombinedCache {
let subcache = SharedCache(standalone: false,
viewPositions: Array<CGPoint>(repeating: .zero, count: subviews.count),
viewRotations: Array<Angle>(repeating: .zero, count: subviews.count))
return CombinedCache(wheel: WheelLayout(radius: wheelRadius, rotation: wheelRotation, pointToCenter: true),
wave: WaveLayout(spacing: 5, amplitude: waveAmplitude, frequency: waveFrequency, phase: wavePhase),
common: subcache)
}
// The interpolation of sizes is only a rough estimation. If a more precise implementation is needed,
// we should look at all the views to make sure they all fit in the calculated space.
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CombinedCache) -> CGSize {
let wheelSize = cache.wheel.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache.common)
let waveSize = cache.wave.sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache.common)
return CGSize(width: wheelSize.width * pct + waveSize.width * (1-pct),
height: wheelSize.height * pct + waveSize.height * (1-pct))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CombinedCache) {
// Run the placeSubviews logic for the wheel
cache.wheel.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache.common)
let wheelPositions = cache.common.viewPositions
let wheelAngles: [Angle] = cache.common.viewRotations
// Run the placeSubviews logic for the wave
cache.wave.placeSubviews(in: bounds, proposal: proposal, subviews: subviews, cache: &cache.common)
let wavePositions = cache.common.viewPositions
let waveAngles: [Angle] = cache.common.viewRotations
// Interpolate positions
let finalPositions = wheelPositions.indices.map { idx in
CGPoint(x: wheelPositions[idx].x * pct + wavePositions[idx].x * (1-pct),
y: wheelPositions[idx].y * pct + wavePositions[idx].y * (1-pct))
}
// Interpolate angles
let finalAngles = waveAngles.indices.map { idx in
wheelAngles[idx] * pct + waveAngles[idx] * (1-pct)
}
// Final positioning of views
for idx in subviews.indices {
subviews[idx].place(at: finalPositions[idx], anchor: .center, proposal: proposal)
}
DispatchQueue.main.async {
for idx in subviews.indices {
subviews[idx][Rotation.self]?.wrappedValue = finalAngles[idx]
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment