Shake-to-report feedback for iOS: full Swift tutorial + SDK

Build shake-to-report for iOS in Swift: detect the shake with motionEnded, screenshot with UIGraphicsImageRenderer, annotate with PencilKit — or use one SDK.

Catch Team··9 min read

Detecting a shake on iOS takes about ten lines: override motionEnded(_:with:) on any UIResponder — or once on UIWindow to catch it app-wide — and check for UIEvent.EventSubtype.motionShake. Detection is the short part. The part worth planning is everything the shake should trigger: screenshot the current screen, let the user circle the broken thing, collect a note plus device context, and upload it somewhere you'll actually look. This tutorial builds that full pipeline in plain Swift with zero dependencies, then shows the one-line SDK version. Shake-to-report earns its keep because users almost never report bugs when reporting means leaving your app to write an email — a shake keeps the report two taps away from the bug.

How do you detect a shake gesture on iOS?

Override motionEnded(_:with:) and check for .motionShake. UIKit synthesizes the shake gesture for you — no Core Motion, no accelerometer thresholds, no low-pass filter tuning. UIKit delivers motion events to the first responder and forwards them up the responder chain, and it only reports when the motion starts and ends — no intermediate shakes.

The minimal version lives in a view controller. For UIKit to deliver the event, something in the chain has to be the first responder:

import UIKit

class BuggyScreenViewController: UIViewController {
    override var canBecomeFirstResponder: Bool { true }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        becomeFirstResponder()
    }

    override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        guard motion == .motionShake else { return }
        print("Shake detected — present the reporter here")
    }
}

This detects a shake on one screen. If a text field currently holds first-responder status, the event still reaches you — the default implementation of motionEnded forwards the message up the chain. The real problem is repetition: every screen needs the same three overrides.

How do you detect a shake app-wide (and in SwiftUI)?

Override motionEnded once on UIWindow. Every responder chain ends at the window, so one override catches every shake on every screen — and it's the standard way to detect a shake in SwiftUI, which never hands you a view controller to override. Post a notification so anything in the app can react:

import UIKit

extension Notification.Name {
    static let deviceDidShake = Notification.Name("deviceDidShake")
}

extension UIWindow {
    open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        if motion == .motionShake {
            NotificationCenter.default.post(name: .deviceDidShake, object: nil)
        }
        super.motionEnded(motion, with: event)
    }
}

One honest caveat: overriding an inherited method from a Swift extension is something the Swift book tells you not to rely on. It works here because motionEnded is an Objective-C method, so dispatch goes through the Objective-C runtime — but if it makes you itch, install a UIWindow subclass from your scene delegate instead. SwiftUI apps don't expose the window, which is why the extension is the usual answer there.

The SwiftUI side is then a one-line onReceive:

import SwiftUI

struct ContentView: View {
    @State private var showReporter = false

    var body: some View {
        Text("Shake the device to report a bug")
            .onReceive(NotificationCenter.default.publisher(for: .deviceDidShake)) { _ in
                showReporter = true
            }
            .sheet(isPresented: $showReporter) {
                Text("Reporter UI goes here")
            }
    }
}

Test it in the Simulator with Device → Shake (⌃⌘Z). No accelerometer required.

Capture the screen with UIGraphicsImageRenderer

When the shake fires, snapshot the key window with UIGraphicsImageRenderer and drawHierarchy(in:afterScreenUpdates:). It renders the view hierarchy exactly as it appears on screen — which is the point: the user is reporting what they're looking at right now.

import UIKit

func captureScreen() -> UIImage? {
    let scenes = UIApplication.shared.connectedScenes
    guard
        let scene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
        let window = scene.keyWindow
    else { return nil }

    let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
    return renderer.image { _ in
        window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
    }
}

Three notes worth knowing before you ship this:

  • UIWindowScene.keyWindow is iOS 15+. On earlier targets you're filtering scene.windows for isKeyWindow yourself.
  • afterScreenUpdates: false snapshots the current state without forcing a layout pass. Pass true only if you just mutated the UI and need the change included — it's slower.
  • drawHierarchy returns false when image data is missing for some view. Video players, Metal layers, and DRM-protected content can come out black. Decide whether that's acceptable before a user does.

Annotate the screenshot with PencilKit

A red circle around the broken button beats three paragraphs of description. PencilKit gives you a complete drawing surface for free: lay a transparent PKCanvasView over the screenshot, hand it a pen, and flatten the drawing onto the image when the user is done.

import UIKit
import PencilKit

final class AnnotationViewController: UIViewController {
    private let screenshotView = UIImageView()
    private let canvasView = PKCanvasView()
    private let screenshot: UIImage

    init(screenshot: UIImage) {
        self.screenshot = screenshot
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        screenshotView.image = screenshot
        screenshotView.contentMode = .scaleAspectFit

        canvasView.backgroundColor = .clear
        canvasView.isOpaque = false
        canvasView.drawingPolicy = .anyInput  // fingers draw too, not just Apple Pencil
        canvasView.tool = PKInkingTool(.pen, color: .systemRed, width: 6)

        for subview in [screenshotView, canvasView] {
            subview.frame = view.bounds
            subview.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            view.addSubview(subview)
        }
    }

    /// Flattens the user's drawing onto the screenshot.
    func annotatedImage() -> UIImage {
        let renderer = UIGraphicsImageRenderer(size: screenshot.size)
        return renderer.image { _ in
            screenshot.draw(in: CGRect(origin: .zero, size: screenshot.size))
            canvasView.drawing
                .image(from: canvasView.bounds, scale: screenshot.scale)
                .draw(in: CGRect(origin: .zero, size: screenshot.size))
        }
    }
}

Two details matter. drawingPolicy = .anyInput (iOS 14+) lets fingers draw — the default policy can restrict drawing to Apple Pencil on iPad, and most bug reporters use a thumb. And PKDrawing.image(from:scale:) renders the strokes into a UIImage so a second UIGraphicsImageRenderer pass can composite them over the screenshot. The snippet assumes the canvas is full-screen so its coordinates line up with the screenshot's points; if you letterbox the image, you own the aspect-fit math — exactly the kind of boring code that quietly accumulates in this feature.

Attach context and upload with URLSession

A message and a screenshot are only half a bug report. Attach what the user can't tell you, automatically: app version and build, OS version, device model, locale, and — if you have one — a user id. Then hand-roll the multipart POST; URLSession needs no help from a networking library.

import UIKit

struct FeedbackReport {
    let message: String
    let screenshot: UIImage?
    let userId: String?
    let email: String?
}

enum FeedbackUploader {
    static func send(_ report: FeedbackReport,
                     to endpoint: URL,
                     accessKey: String,
                     completion: @escaping (Result<Void, Error>) -> Void) {
        let boundary = "----feedback-\(UUID().uuidString)"
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
        request.setValue(accessKey, forHTTPHeaderField: "x-access-token")

        var fields: [String: String] = [
            "message": report.message,
            "platform": "ios",
            "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
            "appBuild": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "",
            "osVersion": UIDevice.current.systemVersion,
            "device": deviceModel(),
            "locale": Locale.current.identifier,
            "reportId": UUID().uuidString,  // idempotency key — reuse it on retry
        ]
        fields["userId"] = report.userId
        fields["email"] = report.email

        var body = Data()
        for (name, value) in fields {
            body.append("--\(boundary)\r\n")
            body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
            body.append("\(value)\r\n")
        }
        if let png = report.screenshot?.pngData() {
            body.append("--\(boundary)\r\n")
            body.append("Content-Disposition: form-data; name=\"screenshot\"; filename=\"screenshot.png\"\r\n")
            body.append("Content-Type: image/png\r\n\r\n")
            body.append(png)
            body.append("\r\n")
        }
        body.append("--\(boundary)--\r\n")
        request.httpBody = body

        URLSession.shared.dataTask(with: request) { _, response, error in
            if let error { return completion(.failure(error)) }
            guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
                return completion(.failure(URLError(.badServerResponse)))
            }
            completion(.success(()))
        }.resume()
    }

    private static func deviceModel() -> String {
        var systemInfo = utsname()
        uname(&systemInfo)
        return withUnsafeBytes(of: &systemInfo.machine) { buffer in
            String(decoding: buffer.prefix(while: { $0 != 0 }), as: UTF8.self)
        }
    }
}

private extension Data {
    mutating func append(_ string: String) {
        append(Data(string.utf8))
    }
}

The reporter screen itself — a UITextView, a character count, a Send button wired to FeedbackUploader.send — is sixty lines of Auto Layout you've written before, so we'll spare you the listing. The one design rule that matters: nothing leaves the device until the user taps Send. You capture the screenshot locally, the user sees exactly what will be uploaded, and cancel means cancel.

Should you build shake-to-report or use an SDK?

Build it if this is an internal tool or a dogfood build — the code above is most of a working internal version, and owning it is fine at that scale. Reach for an in-app feedback SDK when real users will touch it, because the pipeline is where the edge cases live, and you'll own every one:

  • Windows you didn't render. The keyboard and system alerts live in their own UIWindows, so a key-window snapshot omits them. Often a feature, occasionally a confusing hole in the screenshot. iPad multi-scene apps add a second question: which foreground scene?
  • Screens you shouldn't render. Password fields, one-time codes, DRM video. You need a redaction policy, not a shrug.
  • Memory. A full-screen render at device scale on a big iPad is a multi-megapixel bitmap, and you'll briefly hold several (original, strokes, flattened). Downscale or JPEG-encode early or enjoy the memory-warning reports — about your reporter.
  • Shake collisions. Shake already means undo in text fields: applicationSupportsShakeToEdit defaults to true, so a user shaking mid-edit can get the system Undo alert instead of your reporter. Pick a policy and test it.
  • Delivery. Payload limits, and retries that can't duplicate reports — that's the reportId idempotency key above, which the server dedupes so a replayed upload returns the original report instead of creating a second one.

None of this is hard. All of it is time, and none of it is your product.

The SDK version: CatchDev

CatchDev is our in-app feedback SDK for iOS — the entire pipeline above, with shake-to-report on by default. One line at launch:

import SwiftUI
import CatchDev

@main
struct MyApp: App {
    init() {
        CatchDev.start(apiKey: "ck_your_key")
    }
    var body: some Scene { WindowGroup { ContentView() } }
}

A shake presents the reporter: screenshot, freehand annotation, note, Send. Reports land in your dashboard under App → Feedback with the annotated screenshot, message, and device context — the same fields we attached by hand in the upload step above.

It's built the way this post is: UIKit and URLSession, zero third-party dependencies, iOS/iPadOS 15+, and it works from SwiftUI, UIKit, or Objective-C, because it makes no assumptions about your app's architecture. The knobs match what you'd build yourself:

CatchDev.setUser(id: "123", email: "user@example.com")  // attach an identity
CatchDev.setMetadata(["screen": "Checkout"])            // custom key/values
CatchDev.setEnvironment("production")                   // tag the environment
CatchDev.isShakeToReportEnabled = false                 // bring your own trigger…
CatchDev.presentReporter()                              // …and open it manually

Privacy works the way you'd hope: the SDK captures everything on-device and sends nothing until the user taps Send. To be clear about scope — this SDK captures user feedback, not crashes; our error tracking is a separate, web-only SDK family.

CatchDev for iOS is in early access (the repo is private for now — setup and access details are in the iOS docs). It's free for hobby apps: create an App and grab a ck_ key to try it.

The same flow on Android and the web

Android gets the identical pipeline — shake via the sensors, screenshot, annotation, note — in a Kotlin SDK; see the Android docs. The web has no shake gesture, so our web SDK uses a floating launcher button instead and captures the screen with the browser-native getDisplayMedia — a trade-off we unpack in html2canvas vs getDisplayMedia for in-browser screenshots. Different triggers, same report in the same dashboard.

Catch Team

Building catch.dev — the tiny SDK for in-app feedback and crash reports.

// More from the blog