Skip to content

Instantly share code, notes, and snippets.

@mattyoung
Last active October 12, 2023 19:35
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 mattyoung/82a75f5f002805a380dc89764b3113af to your computer and use it in GitHub Desktop.
Save mattyoung/82a75f5f002805a380dc89764b3113af to your computer and use it in GitHub Desktop.
//
// 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