Skip to content

Commit

Permalink
render SwiftUI using custom WaveformShape
Browse files Browse the repository at this point in the history
The interface could arguably be a little simpler:

- `Style` should be reduced to `.solid` and `.striped`
- the actual styling could then be simplified to solely rely on SwiftUI Shape modifiers

However, while we still allow the creation of `UIImage` / `NSImage` as well as support `UIKit`, we can't really simplify this without too much effort in the legacy (`UIKit` code),
which feels like a waste of time.

Instead, probably UIKit support should be dropped at some point in the future and the interface be overhauled for modern iOS.

Once released, this would allow #78 to be done more natively.
  • Loading branch information
dmrschmidt committed Oct 8, 2023
1 parent e670b8d commit e35e2a6
Show file tree
Hide file tree
Showing 7 changed files with 314 additions and 107 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,34 @@ import SwiftUI

@available(iOS 14.0, *)
struct SwiftUIExampleView: View {
private enum ActiveTab: Hashable {
case recorder, shape, overview
}

private static let colors = [UIColor.systemPink, UIColor.systemBlue, UIColor.systemGreen]
private static var randomColor: UIColor { colors.randomElement()! }

private static var audioURLs: [URL?] = [
Bundle.main.url(forResource: "example_sound", withExtension: "m4a"),
Bundle.main.url(forResource: "example_sound_2", withExtension: "m4a")
]
private static var randomURL: URL? { audioURLs.randomElement()! }
private static func randomURL(_ current: URL?) -> URL? { audioURLs.filter { $0 != current }.randomElement()! }

@StateObject private var audioRecorder: AudioRecorder = AudioRecorder()

@State private var audioURL: URL? = Self.randomURL

@State var configuration: Waveform.Configuration = Waveform.Configuration(
style: .outlined(.blue, 3),
verticalScalingFactor: 0.5
@State private var configuration: Waveform.Configuration = Waveform.Configuration(
style: .striped(Waveform.Style.StripeConfig(color: Self.randomColor)),
verticalScalingFactor: 0.9
)

@State var liveConfiguration: Waveform.Configuration = Waveform.Configuration(
@State private var liveConfiguration: Waveform.Configuration = Waveform.Configuration(
style: .striped(.init(color: randomColor, width: 3, spacing: 3))
)

@State var silence: Bool = true
@State var selection: Bool = true
@State private var audioURL: URL? = audioURLs.first!
@State private var samples: [Float] = []
@State private var silence: Bool = true
@State private var selection: ActiveTab = .overview

var body: some View {
VStack {
Expand All @@ -36,16 +40,17 @@ struct SwiftUIExampleView: View {

if #available(iOS 15.0, *) {
Picker("Hey", selection: $selection) {
Text("Recorder Example").tag(true)
Text("Overview").tag(false)
Text("Recorder").tag(ActiveTab.recorder)
Text("Shape").tag(ActiveTab.shape)
Text("Overview").tag(ActiveTab.overview)
}
.pickerStyle(.segmented)
.padding(.horizontal)

if selection {
recordingExample
} else {
overview
switch selection {
case .recorder: recordingExample
case .shape: shape
case .overview: overview
}
} else {
Text("WaveformView & WaveformLiveCanvas require iOS 15.0")
Expand All @@ -57,58 +62,97 @@ struct SwiftUIExampleView: View {
@available(iOS 15.0, *)
@ViewBuilder
private var recordingExample: some View {
HStack {
Button {
configuration = configuration.with(style: .filled(Self.randomColor))
liveConfiguration = liveConfiguration.with(style: .striped(.init(color: Self.randomColor, width: 3, spacing: 3)))
} label: {
Label("color", systemImage: "arrow.triangle.2.circlepath")
.frame(maxWidth: .infinity)
}
.font(.body.bold())
.padding()
.background(Color(.systemGray6))
.cornerRadius(10)

Button {
audioURL = Self.randomURL
print("will draw \(audioURL!)")
} label: {
Label("waveform", systemImage: "arrow.triangle.2.circlepath")
.frame(maxWidth: .infinity)
}
.font(.body.bold())
.padding()
.background(Color(.systemGray6))
.cornerRadius(10)
}
.padding()

if let audioURL {
WaveformView(
audioURL: audioURL,
configuration: configuration,
renderer: CircularWaveformRenderer(kind: .ring(0.7))
)
}

VStack {
Toggle("draw silence", isOn: $silence).padding()

WaveformLiveCanvas(
samples: audioRecorder.samples,
configuration: liveConfiguration,
renderer: CircularWaveformRenderer(kind: .circle),
shouldDrawSilencePadding: silence
)

Toggle("draw silence", isOn: $silence)
.controlSize(.mini)
.padding(.horizontal)

RecordingIndicatorView(
samples: audioRecorder.samples,
duration: audioRecorder.recordingTime,
isRecording: $audioRecorder.isRecording
)
.padding(.horizontal)
}
}

@available(iOS 15.0, *)
@ViewBuilder
private var shape: some View {
VStack {
Text("WaveformView").font(.monospaced(.title.bold())())

RecordingIndicatorView(
samples: audioRecorder.samples,
duration: audioRecorder.recordingTime,
isRecording: $audioRecorder.isRecording
)
.padding()
HStack {
Button {
configuration = configuration.with(style: .striped(Waveform.Style.StripeConfig(color: Self.randomColor)))
liveConfiguration = liveConfiguration.with(style: .striped(.init(color: Self.randomColor, width: 3, spacing: 3)))
} label: {
Label("color", systemImage: "dice")
.frame(maxWidth: .infinity)
}
.font(.body.bold())
.padding(8)
.background(Color(.systemGray6))
.cornerRadius(10)

Button {
audioURL = Self.randomURL(audioURL)
print("will draw \(audioURL!)")
} label: {
Label("waveform", systemImage: "dice")
.frame(maxWidth: .infinity)
}
.font(.body.bold())
.padding(8)
.background(Color(.systemGray6))
.cornerRadius(10)
}
.padding(.horizontal)

// the if let is left here intentionally to illustrate how to deal with optional URLs
// as this was asked in an older GitHub issue
if let audioURL {
WaveformView(audioURL: audioURL, configuration: configuration)

WaveformView(
audioURL: audioURL,
configuration: configuration,
renderer: CircularWaveformRenderer(kind: .ring(0.7))
) { shape in
// you may completely override the shape styling this way
shape
.stroke(LinearGradient(colors: [.red, Color(Self.randomColor)], startPoint: .zero, endPoint: .topTrailing), lineWidth: 3)
}

Divider()
Text("WaveformShape").font(.monospaced(.title.bold())())

/// **Note:** It's possible, but discouraged to use WaveformShape directly.
/// As Shapes should not do any expensive computations, the analyzing should happen outside,
/// hence making the API a tiny bit clumsy if used directly, since we do require to know the size,
/// even though the Shape of course intrinsically knows its size already.
GeometryReader { geometry in
WaveformShape(samples: samples)
.fill(Color.orange)
.task {
do {
let samplesNeeded = Int(geometry.size.width * configuration.scale)
let samples = try await WaveformAnalyzer(audioAssetURL: audioURL)!.samples(count: samplesNeeded)
await MainActor.run { self.samples = samples }
} catch {
assertionFailure(error.localizedDescription)
}
}
}
}
}
}

@ViewBuilder
Expand All @@ -117,34 +161,49 @@ struct SwiftUIExampleView: View {
HStack {
VStack {
WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)))
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.black, 0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])))
WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)))
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 1))))

WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black)))) { shape in
shape // override the shape styling
.stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3)
}
}

VStack {
WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)), renderer: CircularWaveformRenderer())
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.black, 0.5)), renderer: CircularWaveformRenderer())
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)), renderer: CircularWaveformRenderer())
WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])), renderer: CircularWaveformRenderer())
WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)), renderer: CircularWaveformRenderer())
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 2))), renderer: CircularWaveformRenderer())

WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black))), renderer: CircularWaveformRenderer()) { shape in
shape // override the shape styling
.stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3)
}
}

VStack {
WaveformView(audioURL: audioURL, configuration: .init(style: .filled(.red)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.black, 0.5)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .outlined(.blue, 0.5)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .gradient([.yellow, .orange])), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .gradientOutlined([.yellow, .orange], 1)), renderer: CircularWaveformRenderer(kind: .ring(0.5)))
WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .red, width: 2, spacing: 2))), renderer: CircularWaveformRenderer(kind: .ring(0.5)))

WaveformView(audioURL: audioURL, configuration: .init(style: .striped(.init(color: .black))), renderer: CircularWaveformRenderer(kind: .ring(0.5))) { shape in
shape // override the shape styling
.stroke(LinearGradient(colors: [.blue, .pink], startPoint: .bottom, endPoint: .top), lineWidth: 3)
}
}
}
}
}
}

@available(iOS 15.0, *)
struct LiveRecordingView_Previews: PreviewProvider {
struct SwiftUIExampleView_Previews: PreviewProvider {
static var previews: some View {
SwiftUIExampleView()
}
Expand Down
23 changes: 14 additions & 9 deletions Sources/DSWaveformImage/Renderers/CircularWaveformRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import CoreGraphics
*/

public struct CircularWaveformRenderer: WaveformRenderer {
public enum Kind {
public enum Kind: Sendable {
/// Draws waveform as a circular amplitude envelope.
case circle

Expand All @@ -25,11 +25,16 @@ public struct CircularWaveformRenderer: WaveformRenderer {
self.kind = kind
}

public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
public func path(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath {
switch kind {
case .circle: drawCircle(samples: samples, on: context, with: configuration, lastOffset: lastOffset)
case .ring: drawRing(samples: samples, on: context, with: configuration, lastOffset: lastOffset)
case .circle: return circlePath(samples: samples, with: configuration, lastOffset: lastOffset)
case .ring: return ringPath(samples: samples, with: configuration, lastOffset: lastOffset)
}
}

public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
let path = path(samples: samples, with: configuration, lastOffset: lastOffset)
context.addPath(path)

style(context: context, with: configuration)
}
Expand All @@ -49,7 +54,7 @@ public struct CircularWaveformRenderer: WaveformRenderer {
}
}

private func drawCircle(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
private func circlePath(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath {
let graphRect = CGRect(origin: .zero, size: configuration.size)
let maxRadius = CGFloat(min(graphRect.maxX, graphRect.maxY) / 2.0) * configuration.verticalScalingFactor
let center = CGPoint(x: graphRect.maxX * 0.5, y: graphRect.maxY * 0.5)
Expand Down Expand Up @@ -78,12 +83,12 @@ public struct CircularWaveformRenderer: WaveformRenderer {
}

path.closeSubpath()
context.addPath(path)
return path
}

private func drawRing(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
private func ringPath(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath {
guard case let .ring(config) = kind else {
return
fatalError("called with wrong kind")
}

let graphRect = CGRect(origin: .zero, size: configuration.size)
Expand Down Expand Up @@ -121,7 +126,7 @@ public struct CircularWaveformRenderer: WaveformRenderer {
}

path.closeSubpath()
context.addPath(path)
return path
}

private func stripeBucket(_ configuration: Waveform.Configuration) -> Int {
Expand Down
15 changes: 9 additions & 6 deletions Sources/DSWaveformImage/Renderers/LinearWaveformRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,26 @@ import CoreGraphics
public struct LinearWaveformRenderer: WaveformRenderer {
public init() {}

public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
public func path(samples: [Float], with configuration: Waveform.Configuration, lastOffset: Int) -> CGPath {
let graphRect = CGRect(origin: CGPoint.zero, size: configuration.size)
let positionAdjustedGraphCenter = 0.5 * graphRect.size.height
var path = CGMutablePath()

path.move(to: CGPoint(x: 0, y: positionAdjustedGraphCenter))

if case .striped = configuration.style {
path = draw(samples: samples, on: context, path: path, with: configuration, lastOffset: lastOffset, sides: .both)
path = draw(samples: samples, path: path, with: configuration, lastOffset: lastOffset, sides: .both)
} else {
path = draw(samples: samples, on: context, path: path, with: configuration, lastOffset: lastOffset, sides: .up)
path = draw(samples: samples.reversed(), on: context, path: path, with: configuration, lastOffset: lastOffset, sides: .down)
path = draw(samples: samples, path: path, with: configuration, lastOffset: lastOffset, sides: .up)
path = draw(samples: samples.reversed(), path: path, with: configuration, lastOffset: lastOffset, sides: .down)
}

path.closeSubpath()
context.addPath(path)
return path
}

public func render(samples: [Float], on context: CGContext, with configuration: Waveform.Configuration, lastOffset: Int) {
context.addPath(path(samples: samples, with: configuration, lastOffset: lastOffset))
defaultStyle(context: context, with: configuration)
}

Expand All @@ -41,7 +44,7 @@ public struct LinearWaveformRenderer: WaveformRenderer {
case up, down, both
}

private func draw(samples: [Float], on context: CGContext, path: CGMutablePath, with configuration: Waveform.Configuration, lastOffset: Int, sides: Sides) -> CGMutablePath {
private func draw(samples: [Float], path: CGMutablePath, with configuration: Waveform.Configuration, lastOffset: Int, sides: Sides) -> CGMutablePath {
let graphRect = CGRect(origin: CGPoint.zero, size: configuration.size)
let positionAdjustedGraphCenter = 0.5 * graphRect.size.height
let drawMappingFactor = 0.5 * graphRect.size.height * configuration.verticalScalingFactor // we always draw in the center now
Expand Down
Loading

0 comments on commit e35e2a6

Please sign in to comment.