I have a UI that allows someone to move a dial and the dial 'snaps' to each 'mark' on the dial.
I want to add sound to this and I've made a very short 'click' sound that is a fraction of a second.
I don't want to restrict how fast the user can rotate the dial, but I want the sound to play as the dial goes to each mark.
So I need a fast and responsive Audio library to use, however I also know I need to limit how many times it's played in case they spin it so quickly that otherwise the sound would become a constant noise, rather than distinct clicks.
I've seen comments that AVFoundation
is too slow and that AVAudioEngine
was going to give a better performance, but I'm still not sure if that's the best approach and how to tackle limiting the 'repetitive sound' so it's not just a horrendous noise.
I realise this is kind of something that games programmers deal with more than non-game iOS app developers deal with but I'm still stuck for an approach.
One approach...
Play the "click" sound every time the "current tick mark" changes.
This will be slightly different, depending on how you are animating the "dial" -- but the concept is the same. Let's use a scroll view for example.
For the scrollable content, we'll use a view and draw a vertical "tick mark" every 20-points, taller on even 100-points positions. We'll also overlay a view with a single vertical line near the horizontal center - so we want to play a "click" when a tick hits that line. And we'll size things so we can only scroll horizontally.
It will look like this:
and after scrolling a little:
When implementing scrollViewDidScroll(...)
with a typical scroll view, it is very easy to scroll quickly... so quickly, that the .contentOffset.x
can change 200+ points between calls.
If we try to play the tick sound for every 20-points of change, we could be playing it 10 times at essentially the same time.
So, we could create a class property:
var prevTickMark: Int = 0
then calculate the current tick mark in scrollViewDidScroll(...)
. If the values are different, play a tick sound:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
var cx = Int(scrollView.contentOffset.x)
// offset to the first tick-mark
cx += Int(scrollView.contentInset.left)
let curTick: Int = cx / 20
if prevTickMark != curTick {
// we just passed, or we are on, a new "tick"
// so play the tick sound
AudioServicesPlayAlertSound(SystemSoundID(1057))
prevTickMark = curTick
}
}
If we are scrolling / dragging very, very quickly, we don't need a click for every tick mark... because we are not seeing every tick mark cross the center-line.
As the scrolling decelerates -- or when dragging slowly -- we'll get a click on every tick.
Here's some quick example code to try out...
TickView - ticks every 20-points
class TickView: UIView {
lazy var tickLayer: CAShapeLayer = self.layer as! CAShapeLayer
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder:aDecoder)
commonInit()
}
private func commonInit() {
tickLayer.fillColor = nil
tickLayer.strokeColor = UIColor.red.cgColor
backgroundColor = .yellow
}
override func layoutSubviews() {
super.layoutSubviews()
let y: CGFloat = bounds.maxY * 0.75
let shortTick: CGFloat = bounds.maxY * 0.25
let tallTick: CGFloat = bounds.maxY * 0.5
let bez = UIBezierPath()
var pt: CGPoint = .init(x: bounds.minX, y: y)
// horizontal line full width of view
bez.move(to: pt)
bez.addLine(to: .init(x: bounds.maxX, y: pt.y))
// add vertical "tick" lines every 20-points
// with a taller line every 100-points
bez.move(to: pt)
while pt.x <= bounds.maxX {
bez.move(to: pt)
if Int(pt.x) % 100 == 0 {
bez.addLine(to: .init(x: pt.x, y: pt.y - tallTick))
} else {
bez.addLine(to: .init(x: pt.x, y: pt.y - shortTick))
}
pt.x += 20.0
}
tickLayer.path = bez.cgPath
}
}
MidLineView - vertical line to overlay on the scroll view
class MidLineView: UIView {
lazy var midLineLayer: CAShapeLayer = self.layer as! CAShapeLayer
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder:aDecoder)
commonInit()
}
private func commonInit() {
midLineLayer.fillColor = nil
midLineLayer.strokeColor = UIColor.blue.cgColor
backgroundColor = .clear
}
override func layoutSubviews() {
super.layoutSubviews()
let bez = UIBezierPath()
// we want the mid line to be *about* at the horizontal center
// but at an even 20-points
var x: Int = Int(bounds.midX)
x -= x % 20
bez.move(to: .init(x: CGFloat(x), y: bounds.minY))
bez.addLine(to: .init(x: CGFloat(x), y: bounds.maxY))
midLineLayer.path = bez.cgPath
}
}
ViewController - example controller
class ViewController: UIViewController, UIScrollViewDelegate {
let scrollView = UIScrollView()
// view with "tick-mark" lines every 20-points
let tickView = TickView()
// view with single vertical line
// overlay on the scroll view so we have a
// "center-line"
let midLineView = MidLineView()
// track the previous "tick"
var prevTickMark: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
scrollView.translatesAutoresizingMaskIntoConstraints = false
tickView.translatesAutoresizingMaskIntoConstraints = false
midLineView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(tickView)
view.addSubview(scrollView)
view.addSubview(midLineView)
let g = view.safeAreaLayoutGuide
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
scrollView.heightAnchor.constraint(equalToConstant: 120.0),
scrollView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
tickView.topAnchor.constraint(equalTo: cg.topAnchor),
tickView.leadingAnchor.constraint(equalTo: cg.leadingAnchor),
tickView.trailingAnchor.constraint(equalTo: cg.trailingAnchor),
tickView.bottomAnchor.constraint(equalTo: cg.bottomAnchor),
// let's make the "tick" view 2000-points wide
// so we have a good amount of scrolling distance
tickView.widthAnchor.constraint(equalToConstant: 2000.0),
tickView.heightAnchor.constraint(equalTo: fg.heightAnchor, multiplier: 1.0),
midLineView.topAnchor.constraint(equalTo: scrollView.topAnchor),
midLineView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
midLineView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
midLineView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
])
scrollView.delegate = self
// disable interaction on the overlaid view
midLineView.isUserInteractionEnabled = false
// so we can see the framing of the scroll view
scrollView.backgroundColor = .lightGray
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// offsets so the "ticks" start and end near the horiztonal center
// on even 20-points
var x: Int = Int(scrollView.frame.width * 0.5)
x -= x % 20
scrollView.contentInset = .init(top: 0.0, left: CGFloat(x), bottom: 0.0, right: scrollView.frame.width - CGFloat(x))
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
var cx: Int = Int(scrollView.contentOffset.x)
// offset to the first tick-mark
cx += Int(scrollView.contentInset.left)
let curTick: Int = cx / 20
if prevTickMark != curTick {
// we just passed, or we are on, a new "tick"
// so play the tick sound
AudioServicesPlayAlertSound(SystemSoundID(1057))
prevTickMark = curTick
}
}
}