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.keyWindowis iOS 15+. On earlier targets you're filteringscene.windowsforisKeyWindowyourself.afterScreenUpdates: falsesnapshots the current state without forcing a layout pass. Passtrueonly if you just mutated the UI and need the change included — it's slower.drawHierarchyreturnsfalsewhen 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:
applicationSupportsShakeToEditdefaults totrue, 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
reportIdidempotency 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.