Last active
July 12, 2023 11:14
-
-
Save ryangittings/adea88a16e7bbecf1ad616adf0e7cfca to your computer and use it in GitHub Desktop.
Fan
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
// | |
// ContentView.swift | |
// Fan | |
// | |
// Created by Ryan Gittings on 10/07/2023. | |
// | |
import SwiftUI | |
struct ContentView: View { | |
let colors: [Color] = [.yellow, .cyan, .red, .purple, .blue, .green] | |
@State var isStacked: Bool = false | |
var body: some View { | |
VStack { | |
Spacer() | |
Fan { | |
ForEach(0..<1) { idx in | |
FanComponent { | |
RoundedRectangle(cornerRadius: 8) | |
.fill(colors[idx%colors.count]) | |
.frame(width: 70, height: 105) | |
.overlay { | |
Text("\(idx+1)") | |
} | |
} | |
} | |
} | |
.background(Color.orange) | |
Fan { | |
ForEach(0..<2) { idx in | |
FanComponent { | |
RoundedRectangle(cornerRadius: 8) | |
.fill(colors[idx%colors.count]) | |
.frame(width: 70, height: 105) | |
.overlay { | |
Text("\(idx+1)") | |
} | |
} | |
} | |
} | |
.background(Color.orange) | |
Fan { | |
ForEach(0..<5) { idx in | |
FanComponent { | |
RoundedRectangle(cornerRadius: 8) | |
.fill(colors[idx%colors.count]) | |
.frame(width: 70, height: 105) | |
.overlay { | |
Text("\(idx+1)") | |
} | |
} | |
} | |
} | |
.background(Color.orange) | |
Fan { | |
ForEach(0..<10) { idx in | |
FanComponent { | |
RoundedRectangle(cornerRadius: 8) | |
.fill(colors[idx%colors.count]) | |
.frame(width: 70, height: 105) | |
.overlay { | |
Text("\(idx+1)") | |
} | |
} | |
} | |
} | |
.background(Color.orange) | |
Spacer() | |
} | |
.background(.white) | |
.onTapGesture { | |
withAnimation(.easeInOut(duration: 4.0)) { | |
isStacked.toggle() | |
} | |
} | |
} | |
} | |
struct FanComponent<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 Rotation: LayoutValueKey { | |
static let defaultValue: Binding<Angle>? = nil | |
} | |
struct Fan: Layout { | |
/// The number of radians for the arc of the spread. | |
/// NB: 180 degrees = Pi radians | |
private var arcRadians: CGFloat { | |
(radius * CGFloat.pi) / 180 | |
} | |
private let radius: CGFloat = 60 | |
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> 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: proposal.replacingUnspecifiedDimensions().width, height: maxSize.height + radius) | |
} | |
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | |
for (index, subview) in subviews.enumerated() { | |
let fanRadius = fanRadius(bounds: bounds, subview: subview, proposal: proposal) | |
let angle = angleForCard(n: index, subviews: subviews) | |
var point = CGPoint(x: bounds.midX, y: bounds.minY) | |
point.x += xOffsetForCard(n: index, subviews: subviews, fanRadius: fanRadius) | |
point.y += yOffsetForCard(n: index, subviews: subviews, fanRadius: fanRadius) | |
subview.place(at: point, anchor: .top, proposal: .unspecified) | |
DispatchQueue.main.async { | |
subview[Rotation.self]?.wrappedValue = .radians(angle) | |
} | |
} | |
} | |
/// - Returns the radius of the circle on which the fanned cards are | |
/// spread out. Computed from the view width and arc for the spread | |
func fanRadius(bounds: CGRect, subview: LayoutSubviews.Element, proposal: ProposedViewSize) -> CGFloat { | |
let sinAngle = sin(arcRadians / 2.0) | |
let dimensions = subview.dimensions(in: proposal) | |
let availableWidth = (bounds.width - dimensions.width) / 2.0 | |
return sinAngle == 0 ? availableWidth : availableWidth / sinAngle | |
} | |
/// - Returns the radius of the circle on which the fanned cards are | |
/// spread out. Computed from the view width and arc for the spread | |
func angleForCard(n: Int, subviews: Subviews) -> CGFloat { | |
let count = CGFloat(subviews.count) | |
let nGaps = max(count - 1, 1) | |
let fraction = (CGFloat(n) - (nGaps / 2)) / nGaps | |
return fraction * arcRadians | |
} | |
/// - Returns the x offset for card n, which may be negative | |
func xOffsetForCard(n: Int, subviews: Subviews, fanRadius: CGFloat) -> CGFloat { | |
return sin(angleForCard(n: n, subviews: subviews)) * fanRadius | |
} | |
/// - Returns the y offset for card n | |
func yOffsetForCard(n: Int, subviews: Subviews, fanRadius: CGFloat) -> CGFloat { | |
return fanRadius - (cos(angleForCard(n: n, subviews: subviews)) * fanRadius) | |
} | |
} | |
#Preview { | |
ContentView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment