Making video demos: Showing gestures in your iOS app Last Updated 03 August 2022 by Michael Forrest
If you want to avoid adding motion graphics for every tap and swipe in your screen recording during post-production, you should add a feature into your app that displays touches when you record.
First we’ll add a window on top of everything else in your app to display touches. Create a UIWindow
, set its windowLevel
to .normal + 1
and attach a rootViewController
.
Add this code to your sceneDelegate.
if UserDefaults.standard.bool(forKey: "show_touches"){
let overlay = UIWindow(windowScene: windowScene)
let touchesController = TouchesViewController()
overlay.rootViewController = touchesController
overlay.windowLevel = .normal + 1
overlay.isHidden = false
overlay.isUserInteractionEnabled = false
touchesController.touchesView.listenForTouches(in: window)
self.overlay = overlay
}
Add the preference into your Settings.plist so it’s accessible under your app in the Settings app.
Next we need our TouchesViewController
class TouchesViewController: UIViewController{
var touchesView = TouchesView()
override func viewDidLoad() {
super.viewDidLoad()
touchesView.add(to: view)
}
}
TouchesView attaches pan and tap gestures to our window, and implements gestureRecognizer(:shouldRecognizeSimultaneouslyWith:)
to always return true so this doesn’t interfere with anything other controls in our app.
class TouchesView: UIView, UIGestureRecognizerDelegate {
fileprivate var fingers = [UIGestureRecognizer: FingerView]()
// load a 'tap' sound (requires a tap.caf file to be included in the app bundle)
// or comment out this line and the lines where tapSound.play() is called.
let tapSound = try! AVAudioPlayer(contentsOf: Bundle.main.url(forResource: "tap", withExtension: "caf")!)
// pin edge-to-edge
func add(to view: UIView){
view.addSubview(self)
isUserInteractionEnabled = false
translatesAutoresizingMaskIntoConstraints = false
leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
topAnchor.constraint(equalTo: view.topAnchor).isActive = true
bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
tapSound.prepareToPlay()
}
// called from outside after window has been added
func listenForTouches(in view: UIView){
let tap = UITapGestureRecognizer(target: self, action: #selector(handle(tap:)))
tap.cancelsTouchesInView = false
tap.delaysTouchesBegan = false
tap.delaysTouchesEnded = false
tap.delegate = self
view.addGestureRecognizer(tap)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handle(pan:)))
pan.cancelsTouchesInView = false
pan.delaysTouchesBegan = false
pan.delaysTouchesEnded = false
pan.delegate = self
view.addGestureRecognizer(pan)
}
@objc func handle(tap: UITapGestureRecognizer){
let position = tap.location(in: self)
if tap.state == .recognized{
let finger = FingerView(frame: .zero)
addSubview(finger)
finger.frame.origin = position
finger.hide(delay: 0.1, duration: 0.5)
tapSound.play()
}
}
@objc func handle(pan: UIPanGestureRecognizer){
if pan.state == .ended || pan.state == .cancelled{
return
}
let position = pan.location(in: self)
let finger = FingerView(frame: .zero)
addSubview(finger)
finger.frame.origin = position
finger.hide(delay: 0.1, duration: 0.5)
UIView.animate(withDuration: 0.3) {
finger.alpha = 0
}
}
// don't interfere with any other interactions
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}
Finally, we’ll create our FingerView. Mine programatically draws a set of concentric rings to fit with my app’s design. You could create your own design or subclass UIImageView
with an image asset.
The hide()
function is called after each view is added in TouchesView’s handle(pan:)
and handle(tap:)
. Adjust the delay and duration parameters at the call site to taste.
class FingerView: UIView{
let rings:[UIView] = stride(from: 2, to: 41, by: 8).map { (radius:CGFloat) in
let ring = UIView(frame: CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2))
ring.layer.borderColor = (radius == 2 ? UIColor.macBlue : UIColor.macSecondary).cgColor
ring.layer.borderWidth = 2
ring.layer.cornerRadius = radius
return ring
}
override init(frame: CGRect) {
super.init(frame: frame)
for ring in rings {
addSubview(ring)
}
}
func hide(delay: TimeInterval, duration: TimeInterval, completion: (()->Void)? = nil){
let fadeDuration = (duration * 0.7 / TimeInterval(rings.count))
let staggerInterval = fadeDuration * 0.7
for (index,ring) in rings.enumerated() {
UIView.animate(withDuration: fadeDuration, delay: TimeInterval(index) * staggerInterval, options: [], animations: {
ring.alpha = 0
})
}
DispatchQueue.main.asyncAfter(deadline: .now() + duration + delay) {
self.removeFromSuperview()
completion?()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Now you can run your app, go to Settings on your device and enable “Show Touches”
I have added the screen recording button to Control Center on my phone to quickly initiate screen recordings.
Here’s what it looks like in context!
jump to about 2:00 for app demos
Hope that helps! Feel free to join my mailing list or follow me on Twitter.