Last active
October 2, 2024 14:49
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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