Last active
November 28, 2024 08:39
-
-
Save mattyoung/82a75f5f002805a380dc89764b3113af to your computer and use it in GitHub Desktop.
This file contains hidden or 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
// | |
// AnimatedSpeakerVolumeWaveSlash.swift | |
// iOS17-New-Beginning | |
// | |
// Created by Matthew Young on 10/12/23. | |
// | |
import SwiftUI | |
struct SpeakerVolumeView: View { | |
let level: Double | |
// Should be a let here but then Swift won't allow you to override this with just the synthesized init | |
// We need @initializable let: https://forums.swift.org/t/explicit-memberwise-initializers/22893 | |
// My asking about this: https://forums.swift.org/t/nice-way-of-copying-an-immutable-value-while-changing-only-a-few-of-its-many-properties/33134/20?u=young | |
// I just prefer not to hand write init() | |
var symbolName = "speaker" | |
var body: some View { | |
ZStack(alignment: .topLeading) { | |
HStack(spacing: 0) { | |
// put a clear/invisible symbol image here for horizontal offset positioning of the soundwave arc's push to the right | |
// because the animate slash is a square which may not be the same width as the image | |
Image(systemName: symbolName) | |
.resizable() | |
.symbolVariant(.fill) | |
.aspectRatio(contentMode: .fit) | |
.hidden() | |
SoundwaveView(level: 1) | |
.opacity(level <= 0 ? 0 : 1) | |
SoundwaveView(level: 2) | |
.opacity(level <= 1 / 3 ? 0 : 1) | |
SoundwaveView(level: 3) | |
.opacity(level <= 3 / 4 ? 0 : 1) | |
} | |
AnimatedSlashView(level: self.level) { | |
// set the width equal to the height, making the animated slash bound to a square | |
SetWidthBasedOnHeightView(width: { $0 }) { _ in | |
HStack(spacing: 0) { | |
Image(systemName: symbolName) | |
.resizable() | |
.symbolVariant(.fill) | |
.scaledToFit() | |
Spacer(minLength: 0) // set minLength = 0 to make it completely disappear if there is no room | |
} | |
} | |
} | |
} | |
.flipsForRightToLeftLayoutDirection(true) | |
.animation(Animation.easeOut(duration: 0.25), value: level) | |
} | |
} | |
/// A view that can draw an animated "slash" from top left to bottom right when the volumeSetting is zero | |
/// retract the slash when volumeSetting is > 0 | |
private struct AnimatedSlashView<Content: View>: View { | |
let level: Double | |
let content: () -> Content | |
var body: some View { | |
content() | |
.clipShape(SlashShape(animatableData: level > 0 ? 0 : 1, asClipShape: true), style: FillStyle(eoFill: true, antialiased: true)) | |
.overlay(SlashShape(animatableData: level > 0 ? 0 : 1)) | |
} | |
} | |
/// An animated diagonal capsule slash shape | |
/// if asClipShape is true, a bounding box is added for use as a clip shape (for eoFill rule) | |
private struct SlashShape: Shape { | |
var animatableData: CGFloat | |
var asClipShape = false | |
func path(in rect: CGRect) -> Path { | |
// the thickness of the slash based on the smaller side of the bounding rect | |
// asClipShape: 3 / 20, else, it's 3 / 40 | |
let thickness: CGFloat = 3 * min(rect.width, rect.height) / (asClipShape ? 20 : 40) | |
// Add some padding on the left and right ends if this is a clip shape | |
let padding: CGFloat = asClipShape ? thickness / 4 : 0 | |
// the length is the diagonal of the bounding react | |
let slashLength = (rect.width * rect.width + rect.height * rect.height).squareRoot() | |
// the angle is how much to rotate so the shape is a diagonal | |
let slashAngle = atan2(rect.height, rect.width) | |
var path = Path() | |
path.addRoundedRect( | |
in: CGRect(x: -padding, y: -thickness / 2, width: animatableData * slashLength + 2 * padding, height: thickness), | |
cornerSize: CGSize(width: thickness / 2, height: thickness / 2), | |
style: .circular, | |
transform: CGAffineTransform(rotationAngle: slashAngle) | |
) | |
// if asClipShape is true, add the entire bounding rect for eoFill rule (clip inside slash shape above, no clip outside here) | |
if asClipShape { | |
path.addRect(rect) | |
} | |
return path | |
} | |
} | |
// An arc representing a soundwave, constrain by its frame height | |
// This view sets its own width to 1/5 of its height | |
private struct SoundwaveView: View { | |
let level: CGFloat // soundwave level of 1, 2 or 3 determine the size of the arc | |
static private let arcAngleHalf = 35.0 // 1/2 of arc Angle | |
var body: some View { | |
SetWidthBasedOnHeightView(width: { $0 / 5 }) { geometry in | |
Path { path in | |
let radius = geometry.size.height / 4 * CGFloat(self.level) | |
let boudingBoxWidth = geometry.size.height / 5 | |
let halfOfLineWidth = geometry.size.height / 25 | |
// the center of the arc is adjusted to so that the arc just touch the right edge of the bounding box | |
path.addArc(center: CGPoint(x: boudingBoxWidth - halfOfLineWidth - radius, y: geometry.size.height / 2), radius: radius, startAngle: .degrees(Self.arcAngleHalf), endAngle: .degrees(360 - Self.arcAngleHalf), clockwise: true) | |
} | |
.stroke(style: StrokeStyle(lineWidth: geometry.size.height / 12.5, lineCap: .round)) | |
} | |
} | |
} | |
/// A GeometryReader container that sets its own width base on the height | |
/// This allows the parent to only specify/constrain only the height dimension (possibly far up the view tree) and | |
/// letting this view determine its width from the given height | |
/// The height value is passed to the client-view provided closure of (CGFloat) -> CGFloat to computes the view's width | |
struct SetWidthBasedOnHeightView<Content: View> : View { | |
let width: (CGFloat) -> CGFloat | |
let content: (GeometryProxy) -> Content | |
// WARNING: This cannot be Optional with nil default because the animation don't look right | |
@State private var viewHeight: CGFloat = 0 // the actual value is received from the .onPrepreferenceChange() modifier | |
var body: some View { | |
GeometryReader { | |
self.content($0) | |
// propergate the view's height value up the vew tree outide | |
.preference(key: CGFloatPreferenceKey.self, value: $0.size.height) | |
} | |
// setting the view's width computed from the view's height value | |
.frame(width: self.width(viewHeight)) | |
// receive the .preference() value here | |
.onPreferenceChange(CGFloatPreferenceKey.self) { self.viewHeight = $0 } | |
} | |
} | |
// This cannot be nested inside WidthSetBasedOnHeightView container | |
// because: Static stored properties not supported in generic types | |
private struct CGFloatPreferenceKey: PreferenceKey { | |
// typealias Value = CGFloat // instead of explicit type | |
static var defaultValue: CGFloat = 0 // let the compiler infer the type instead :) | |
static func reduce(value: inout Value, nextValue: () -> Value) { | |
value = nextValue() | |
} | |
} | |
// =============================== Demo ================================= | |
// MARK: preview | |
// show one widget | |
struct AnimatedSlashSpeakerDemo1: View { | |
private static let widgetHeight: CGFloat = 210 | |
private static let volumeRange = 0.0...1.0 | |
private static let step = 0.01 | |
@State private var volumeSetting = 1.0 | |
private static let colors = [Color.pink, .blue, .red, .green, .purple, .yellow] | |
private static let randomColors = repeatElement(Self.colors, count: 70).flatMap { $0 }.shuffled() | |
var body: some View { | |
ZStack { | |
Color.secondary.opacity(0.8) | |
VStack { | |
Spacer() | |
ZStack { | |
LinearGradient(gradient: Gradient(colors: Self.randomColors), startPoint: .topLeading, endPoint: .bottomTrailing) | |
.frame(width: Self.widgetHeight * 1.6, height: Self.widgetHeight * 1.1) | |
.cornerRadius(30) | |
SpeakerVolumeView(level: self.volumeSetting) | |
.frame(height: Self.widgetHeight) | |
.foregroundColor(.orange) | |
} | |
Spacer() | |
Slider(value: self.$volumeSetting, in: Self.volumeRange, step: Self.step, minimumValueLabel: Image(systemName: "speaker.slash.fill"), maximumValueLabel: Image(systemName: "speaker.3.fill")) { | |
Text("Volume") | |
} | |
.accentColor(.green) | |
.foregroundColor(.green) | |
.padding() | |
} | |
} | |
.edgesIgnoringSafeArea(.all) | |
} | |
} | |
// show a whole bunch widgets inside different places/container | |
// to demonstrate this widget size its width | |
struct AnimatedSlashSpeakerDemo: View { | |
private static let widgetHeight: CGFloat = 180 | |
private static let volumeRange = 0.0...1.0 | |
private static let step = 0.01 | |
@State private var volumeSetting = 1.0 | |
private static let colors = [Color.pink, .blue, .red, .green, .purple, .yellow] | |
private static let randomColors = repeatElement(Self.colors, count: 70).flatMap { $0 } | |
var body: some View { | |
ZStack { | |
Color.secondary.opacity(0.8) | |
VStack { | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting, symbolName: "ant.fill") | |
.frame(height: 220) | |
.foregroundColor(.pink) | |
HStack { | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting) | |
.foregroundColor(.purple) | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting) | |
.foregroundColor(.blue) | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting) | |
.foregroundColor(.red) | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting, symbolName: "bolt.circle.fill") | |
.foregroundColor(.yellow) | |
Spacer() | |
} | |
// only constraint height in this one place, the widgets inside size themself | |
.frame(height: 20) | |
.padding() | |
.background(Color.gray) | |
.cornerRadius(30) | |
.padding(.horizontal) | |
// here constraint each height individually, they set their own width | |
HStack { | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting) | |
.frame(height: 20) | |
.foregroundColor(.purple) | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting) | |
.frame(height: 40) | |
.foregroundColor(.blue) | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting) | |
.frame(height: 60) | |
.foregroundColor(.red) | |
Spacer() | |
SpeakerVolumeView(level: self.volumeSetting, symbolName: "bolt.circle.fill") | |
.frame(height: 80) | |
.foregroundColor(.yellow) | |
Spacer() | |
} | |
.padding() | |
.background(Color.gray) | |
.cornerRadius(30) | |
.padding(.horizontal) | |
// show gradient back to show the slash shape is clipped showing what's behind | |
ZStack { | |
LinearGradient(gradient: Gradient(colors: Self.randomColors.shuffled()), startPoint: .topLeading, endPoint: .bottomTrailing) | |
.frame(width: Self.widgetHeight * 1.6, height: Self.widgetHeight * 1.1) | |
.cornerRadius(30) | |
.flipsForRightToLeftLayoutDirection(true) | |
SpeakerVolumeView(level: self.volumeSetting) | |
.frame(height: Self.widgetHeight) | |
.foregroundColor(.orange) | |
} | |
Spacer() | |
Slider(value: self.$volumeSetting, in: Self.volumeRange, step: Self.step, minimumValueLabel: Image(systemName: "speaker.slash.fill"), maximumValueLabel: Image(systemName: "speaker.3.fill")) { | |
Text("Volume") | |
} | |
.accentColor(.green) | |
.foregroundColor(.green) | |
.padding() | |
} | |
} | |
.edgesIgnoringSafeArea(.all) | |
} | |
} | |
#Preview { | |
AnimatedSlashSpeakerDemo1() | |
} | |
#Preview { | |
AnimatedSlashSpeakerDemo() | |
.previewLayout(.fixed(width: 400, height: 700)) | |
.previewDisplayName("Default preview") | |
} | |
#Preview { | |
AnimatedSlashSpeakerDemo() | |
.environment(\.layoutDirection, .rightToLeft) | |
.previewLayout(.fixed(width: 400, height: 700)) | |
.previewDisplayName(".rightToLeft") | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment