Created
July 11, 2023 20:23
-
-
Save ryangittings/e48a5eee26ce951125c86a2863917a15 to your computer and use it in GitHub Desktop.
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
// | |
// WheelView.swift | |
// ShowcaseShareCard | |
// | |
// Created by Ryan Gittings on 10/07/2023. | |
// | |
import SwiftUI | |
struct ContentView: View { | |
let colors: [Color] = [.yellow, .orange, .red, .purple, .blue, .green] | |
@State var angle: Angle = .zero | |
@State var radius: CGFloat = 140.0 | |
@State var animation: Animation? = nil | |
var body: some View { | |
VStack { | |
Spacer() | |
Wheel(radius: radius, rotation: angle) { | |
contents() | |
} | |
.background(Color.orange) | |
Spacer() | |
} | |
.background(.white) | |
} | |
@ViewBuilder func contents(animation: Animation? = nil) -> some View { | |
ForEach(0..<10) { idx in | |
WheelComponent(animation: animation) { | |
RoundedRectangle(cornerRadius: 8) | |
.fill(colors[idx%colors.count]) | |
.frame(width: 70, height: 105) | |
.overlay { | |
Text("\(idx+1)") | |
} | |
} | |
} | |
} | |
} | |
struct Rotation: LayoutValueKey { | |
static let defaultValue: Binding<Angle>? = nil | |
} | |
struct WheelComponent<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)) | |
} | |
} | |
struct Wheel: 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 | |
private static let arcDegrees: CGFloat = 60 | |
private static let arcRadians = (arcDegrees * CGFloat.pi) / 180 | |
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { | |
let maxSize = subviews.map { $0.sizeThatFits(proposal) }.reduce(CGSize.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) } | |
return CGSize(width: (maxSize.width / 2 + radius) * 2, height: maxSize.height) | |
} | |
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | |
for (index, subview) in subviews.enumerated() { | |
let fanRadius = fanRadius(bounds: bounds, proposal: proposal) | |
let angle = angleForCard(n: index, subviews: subviews) | |
var point = CGPoint(x: sin(angle) * fanRadius, y: 0) | |
point.x += bounds.midX | |
point.y += bounds.midY | |
subview.place(at: point, anchor: .center, proposal: .unspecified) | |
DispatchQueue.main.async { | |
subview[Rotation.self]?.wrappedValue = .radians(angle) | |
} | |
} | |
} | |
func fanRadius(bounds: CGRect, proposal: ProposedViewSize) -> CGFloat { | |
let sinAngle = sin(Wheel.arcRadians / 2.0) | |
let availableWidth = (bounds.size.width - (proposal.width ?? 0)) / 2.0 | |
return sinAngle == 0 ? availableWidth : availableWidth / sinAngle | |
} | |
func angleForCard(n: Int, subviews: Subviews) -> CGFloat { | |
let nGaps = max(CGFloat(subviews.count) - 1, 1) | |
let fraction = (CGFloat(n) - (nGaps / 2)) / nGaps | |
return fraction * -Wheel.arcRadians | |
} | |
} | |
#Preview { | |
ContentView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment