LoopFollowLogo.swift 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. // LoopFollow
  2. // LoopFollowLogo.swift
  3. import SwiftUI
  4. /// The LoopFollow mark, rebuilt in SwiftUI as the full app-icon face — a glassy
  5. /// rounded square with the blue "loop" ring — so it has real visual mass when
  6. /// tilted in 3D (a bare ring collapses to a line edge-on).
  7. ///
  8. /// Geometry and layers mirror the icon source artwork (loopfollow-icon.svg,
  9. /// 1024×1024): all fractions below are the SVG coordinates divided by 1024.
  10. struct LoopFollowLogo: View {
  11. var size: CGFloat = 120
  12. // Colors sampled from the app icon (loopfollow-icon.svg).
  13. private let lightBlue = Color(red: 0.357, green: 0.639, blue: 0.961) // #5BA3F5
  14. private let midBlue = Color(red: 0.290, green: 0.565, blue: 0.886) // #4A90E2
  15. private let darkBlue = Color(red: 0.227, green: 0.482, blue: 0.784) // #3A7BC8
  16. var body: some View {
  17. let corner = size * 0.225
  18. let ringDiameter = size * 0.879 // outer r = 450
  19. let holeDiameter = size * 0.615 // inner r = 315
  20. ZStack {
  21. // Glassy near-white card (the icon face).
  22. RoundedRectangle(cornerRadius: corner, style: .continuous)
  23. .fill(
  24. LinearGradient(
  25. colors: [
  26. Color(red: 0.973, green: 0.976, blue: 0.980), // #F8F9FA
  27. .white,
  28. Color(red: 0.941, green: 0.949, blue: 0.961), // #F0F2F5
  29. ],
  30. startPoint: .topLeading,
  31. endPoint: .bottomTrailing
  32. )
  33. )
  34. // Soft highlight fading down the top half of the card.
  35. LinearGradient(
  36. stops: [
  37. .init(color: .white.opacity(0.6), location: 0),
  38. .init(color: .white.opacity(0.3), location: 0.2),
  39. .init(color: .clear, location: 0.5),
  40. ],
  41. startPoint: .top,
  42. endPoint: .bottom
  43. )
  44. // Curved glass reflection over the upper half.
  45. radialGlow(
  46. width: size * 1.172, height: size * 0.781,
  47. stops: [
  48. .init(color: .white.opacity(0.5), location: 0),
  49. .init(color: .white.opacity(0.2), location: 0.5),
  50. .init(color: .clear, location: 1),
  51. ]
  52. )
  53. .offset(y: -size * 0.25)
  54. .opacity(0.8)
  55. // Blue glass disc.
  56. Circle()
  57. .fill(
  58. LinearGradient(
  59. stops: [
  60. .init(color: lightBlue, location: 0),
  61. .init(color: midBlue, location: 0.3),
  62. .init(color: midBlue, location: 0.7),
  63. .init(color: darkBlue, location: 1),
  64. ],
  65. startPoint: .topLeading,
  66. endPoint: .bottomTrailing
  67. )
  68. )
  69. .frame(width: ringDiameter, height: ringDiameter)
  70. // Darkening toward the disc's outer edge for depth.
  71. Circle()
  72. .fill(
  73. RadialGradient(
  74. stops: [
  75. .init(color: .clear, location: 0.7),
  76. .init(color: .black.opacity(0.15), location: 0.85),
  77. .init(color: .black.opacity(0.25), location: 1),
  78. ],
  79. center: .center,
  80. startRadius: 0,
  81. endRadius: ringDiameter / 2
  82. )
  83. )
  84. .frame(width: ringDiameter, height: ringDiameter)
  85. // White hole that turns the disc into the loop ring.
  86. Circle()
  87. .fill(Color.white.opacity(0.98))
  88. .frame(width: holeDiameter, height: holeDiameter)
  89. // Glass highlight on the white hole.
  90. radialGlow(
  91. width: size * 0.742, height: size * 0.391,
  92. stops: [
  93. .init(color: .white.opacity(0.4), location: 0),
  94. .init(color: .white.opacity(0.1), location: 0.6),
  95. .init(color: .clear, location: 1),
  96. ]
  97. )
  98. .offset(y: -size * 0.129)
  99. .opacity(0.7)
  100. // Broad sheen across the top of the ring; its lower edge draws the
  101. // glass "cut line" just below the middle of the icon.
  102. Ellipse()
  103. .fill(Color.white.opacity(0.25))
  104. .frame(width: size * 0.82, height: size * 0.488)
  105. .offset(y: -size * 0.188)
  106. .blur(radius: size * 0.003)
  107. // Subtle shadow pooling under the ring.
  108. Ellipse()
  109. .fill(Color.black.opacity(0.05))
  110. .frame(width: size * 0.879, height: size * 0.195)
  111. .offset(y: size * 0.184)
  112. .blur(radius: size * 0.002)
  113. }
  114. .frame(width: size, height: size)
  115. .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
  116. }
  117. /// Elliptical radial glow: SwiftUI's RadialGradient is circular, so draw it
  118. /// in a circle and squash vertically to match the SVG's elliptical gradients.
  119. private func radialGlow(width: CGFloat, height: CGFloat, stops: [Gradient.Stop]) -> some View {
  120. Circle()
  121. .fill(
  122. RadialGradient(
  123. gradient: Gradient(stops: stops),
  124. center: .center,
  125. startRadius: 0,
  126. endRadius: width / 2
  127. )
  128. )
  129. .frame(width: width, height: width)
  130. .scaleEffect(x: 1, y: height / width)
  131. }
  132. }
  133. /// LoopFollow logo that lands like a coin: it starts edge-on (rotated 90° about
  134. /// the vertical axis) and springs open to face the viewer, overshooting a little
  135. /// past flat and rocking back to rest. Respects Reduce Motion by rendering flat.
  136. struct AnimatedLoopFollowLogo: View {
  137. var size: CGFloat = 140
  138. @Environment(\.accessibilityReduceMotion) private var reduceMotion
  139. @State private var angle: Double = 90
  140. var body: some View {
  141. LoopFollowLogo(size: size)
  142. .rotation3DEffect(
  143. .degrees(angle),
  144. axis: (x: 0, y: 1, z: 0),
  145. perspective: 0.7
  146. )
  147. // Grounding shadow so the landing reads as dimensional.
  148. .shadow(color: .black.opacity(0.28), radius: size * 0.08, x: 0, y: size * 0.06)
  149. .onAppear {
  150. if reduceMotion {
  151. angle = 0
  152. } else {
  153. withAnimation(.spring(response: 0.85, dampingFraction: 0.5).delay(0.15)) {
  154. angle = 0
  155. }
  156. }
  157. }
  158. }
  159. }