Skip to content

Instantly share code, notes, and snippets.

@ryangittings
Last active July 12, 2023 11:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ryangittings/adea88a16e7bbecf1ad616adf0e7cfca to your computer and use it in GitHub Desktop.
Save ryangittings/adea88a16e7bbecf1ad616adf0e7cfca to your computer and use it in GitHub Desktop.
Fan
//
// 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