最近在想一個功能,就是很多大 App 都有的那個… App 卡住或哪裡怪怪的時候,你就搖一下手機,然後它就跳出一個回報問題的視窗。像 Instagram 就有,對吧?老實說這功能真的蠻讚的,使用者不用大海撈針去找那個「聯絡我們」或「回報問題」的按鈕,當下遇到問題,搖一搖就能講,體驗好很多。
然後我就去查了一下,這東西在 Flutter 上面要自己做會不會很麻煩。結果發現… 欸,其實不會耶,甚至可以說蠻簡單的。今天就來邊想邊記錄一下,怎麼把這個「搖手機回報」功能加到自己的 Flutter App 裡。
重點一句話
簡單講,就是用一個現成的套件 `shake_gesture` 來偵測手機晃動,然後觸發一個 `showModalBottomSheet` 把回報視窗叫出來。整個過程不用寫什麼原生程式碼,大概幾十分鐘就能搞定。
這功能,真的有比較好嗎?
在直接衝進去寫 code 之前,先停下來想一下。這東西真的有比較好嗎?還是只是為了酷炫?我自己是覺得,在某些情境下,它真的很有用。
你可以想成,它是一種「即時情境回饋」的管道。特別是在 App 的 Beta 測試階段,或是給內部 QA 團隊用,這個超方便。測試人員不用再截圖、開另一個通訊軟體、然後描述老半天「我在哪個畫面的哪個按鈕下面看到怪怪的東西」。他只要當下搖一搖,系統甚至可以順便把當前的頁面資訊、使用者 ID 什麼的一起打包送出。
不過呢,當然也有缺點。如果你的 App 是個遊戲,或是一個需要使用者頻繁晃動手機操作的應用… 那可能就會一直誤觸,反而造成困擾。所以,也不是萬靈丹啦。
說到這個,市面上也不是只有一個套件能做這件事,我順手整理了一下幾個選項的感覺:
| 方法 | 優點 | 缺點(或說…要考慮的點) | 適合誰 |
|---|---|---|---|
| 用 `shake_gesture` 套件 | 設定超簡單,幾乎是無腦安裝。文件也寫得很清楚。 | 客製化選項比較少,特別是 iOS 那邊幾乎沒得調。 | 大部分的 App、想快速驗證功能、不想搞太複雜的人。就是我們今天要用的方法。 |
| 用 `sensors_plus` 自己監聽 | 自由度超高!你可以自己決定加速度的閾值、偵測頻率等等。 | 你要自己處理演算法,判斷怎樣才算「一次有效的搖晃」,而不是使用者走路或搭車的晃動。要花點時間調校。 | 對偵測精準度有特殊要求,或是想做得很細緻的專案。 |
| 自己寫原生整合 (MethodChannel) | 終極完全體。效能最好,可以呼叫到最底層的系統 API。 | 開發地獄。你要同時懂 Dart、Kotlin/Java、Swift/Objective-C。時間成本非常非常高。 | 除非你的 App 有幾百萬用戶,對效能跟體驗的要求到頂了,不然真的沒必要自虐。 |
怎麼做
好,廢話不多說。我們就選最簡單的路,用 `shake_gesture` 來做。流程大概是:裝套件 -> 搞定 Android 設定 -> 搞定 iOS 設定 -> 寫 UI。超直覺。
第一步:把套件加進來
這應該不用多說了。打開你的終端機,在專案底下跑這個指令:
flutter pub add shake_gesture
跑完之後,你的 `pubspec.yaml` 檔案裡應該會多一行 `shake_gesture` 的依賴。如果你的 VS Code 或 Android Studio 沒有自動幫你跑 `flutter pub get`,記得自己手動跑一下。
第二步:Android 這邊要加幾行字
Android 這邊需要手動設定一下。你要去打開 `android/app/src/main/AndroidManifest.xml` 這個檔案。對,就是那個看起來很複雜的 XML 檔。
然後在 `
<manifest ...>
<application ...>
<!-- 這兩行是加進去的 -->
<meta-data
android:name="dev.fluttercommunity.shake_gesture_android.SHAKE_FORCE"
android:value="5" />
<meta-data
android:name="dev.fluttercommunity.shake_gesture_android.MIN_NUM_SHAKES"
android:value="6" />
<!-- 其他的 activity 設定 -->
</application>
</manifest>
這兩個值是幹嘛的?
SHAKE_FORCE: 簡單講就是搖晃的力道。數字越小,表示輕輕搖就會觸發。數字越大,你就要像在搖飲料一樣用力。MIN_NUM_SHAKES: 就是要連續搖幾下才會觸發。
原文提供的 `5` 跟 `6` 是預設值。我自己是覺得可以先用這個,然後找幾個同事或朋友試用看看,如果他們覺得太難觸發或太容易誤觸,再來調整就好。
第三步:iOS 這邊… 比較特別
輪到 iOS 了。說真的,每次搞 Flutter 遇到 iOS 都會有點…嗯,你知道的。蘋果有它自己的規矩。
首先,你不用在程式碼裡加什麼設定,但你要打開 Xcode。在你的專案資料夾裡找到 `/ios/Runner.xcodeproj`,用 Xcode 打開它。然後照著 `Runner -> General -> Supported Destinations` 這個路徑點進去。
重點來了:**確保你只勾選了 iPhone 跟 iPad**。如果你勾了 `macOS` 或新的 `visionOS`,這個搖晃偵測功能… 就會直接罷工,連在你的 iPhone 上都沒用。很怪,但就是這樣。
然後,前面有提到,iOS 這邊是沒辦法讓你調整力道或次數的。因為這個套件在 iOS 底層用的是蘋果內建的 `UIEvent.EventType.motion` 事件,也就是 `motionshake`。蘋果並沒有開放參數給開發者調整。這點跟我們在台灣看到很多 App 功能的彈性很不一樣,台灣的 App 開發文化比較偏向快速迭代、給使用者最多選項,但蘋果的 [Apple Human Interface Guidelines] 就非常強調一致性跟簡潔,不希望開發者濫用會干擾主要操作的感測器功能。反過來說,Google 的 [Material Design] 指引就相對開放,鼓勵各種創新的互動方式。所以… 沒辦法,在 iOS 上就是得接受它預設的靈敏度,好不好用就看使用者回饋了。
第四步:把 UI 兜起來
設定都搞定了,終於可以來寫 Dart code 了。我們要寫兩個 Widget:一個是包在 App 外面的「監聽器」,另一個是跳出來的「回報視窗」。
首先是監聽器,我叫它 `ShakeToReport`。它是一個 `StatefulWidget`,因為它需要記錄「回報視窗是不是已經打開了」。
class ShakeToReport extends StatefulWidget {
const ShakeToReport({required this.child, super.key});
final Widget child;
@override
State<ShakeToReport> createState() => _ShakeToReportState();
}
class _ShakeToReportState extends State<ShakeToReport> {
// 這個 flag 很重要,用來防止重複觸發
bool _isModalShown = false;
void _showModal() {
// 如果視窗已經開了,就直接 return,不做事
if (_isModalShown) {
return;
}
// 先把 flag 設為 true,鎖起來
setState(() {
_isModalShown = true;
});
// 叫出回報視窗
showModalBottomSheet(
context: context,
isScrollControlled: true, // 這行蠻好用的,可以讓你的 modal 高度自適應
builder: (_) => const ShakeToReportModal(),
).then((_) {
// then 會在視窗關閉後才執行
// 這時候再把 flag 設回 false,解除鎖定
setState(() {
_isModalShown = false;
});
});
}
@override
Widget build(BuildContext context) {
// 核心就是這個 ShakeGesture Widget
return ShakeGesture(
onShake: _showModal, // 搖了就呼叫 _showModal
child: widget.child, // 把我們的 App 內容放進來
);
}
}
那個 `_isModalShown` 的處理我覺得是個小細節但很重要。不然使用者只要持續搖晃,你的 App 可能會一直瘋狂想跳出新視窗,畫面會爛掉。
再來是回報視窗本身 `ShakeToReportModal`。這個就超簡單了,就是一個 `StatelessWidget`,裡面排版看你高興怎麼做。下面是個極簡範例:
class ShakeToReportModal extends StatelessWidget {
const ShakeToReportModal({super.key});
@override
Widget build(BuildContext context) {
// 拿一下 App 的主題顏色,這樣看起來比較有一致性
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
// 用 SingleChildScrollView 包起來,萬一內容太多手機太小才不會爆掉
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, // 讓按鈕可以滿版
children: [
const Text(
'遇到問題了嗎?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const Text(
'請告訴我們發生了什麼事,您的回饋能幫助我們把 App 變得更好!',
),
const SizedBox(height: 24),
// 這邊可以放文字輸入框、選項等等
// TODO: 加上一個 TextField 讓使用者輸入文字
const SizedBox(height: 24),
TextButton(
onPressed: () {
// TODO: 實作回報的邏輯,例如把資料送到後端 API
Navigator.of(context).pop(); // 按完就關掉視窗
},
style: TextButton.styleFrom(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('送出回饋'),
),
],
),
),
);
}
}
最後一步,就是在你 App 的最外層,把剛剛寫的 `ShakeToReport` 包上去。通常會包在 `MaterialApp` 的 `home` 屬性外面,這樣整個 App 都能偵測到搖晃。
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
// 就是這裡!
home: const ShakeToReport(
child: MyHomePage(title: '我的 App 首頁'),
),
);
}
}
這樣就大功告成了!跑起來你的 App,搖搖看手機,應該就能看到效果了。
還可以怎麼玩?讓體驗更完整
當然,上面只是最基礎的版本。如果真的要放到正式產品上,還有很多可以優化的細節。
- 自動截圖:搖晃的當下,自動截一張目前的畫面。這樣回報問題時,開發者就能立刻知道是哪個頁面出錯,省去大量溝通成本。這需要用到像是 `screenshot` 之類的套件。
- 整合後端服務:送出的回饋總要有地方去吧?你可以把它們送到 Firebase、Sentry,或是自己公司的後端 API。更簡單一點,甚至可以透過 `url_launcher` 直接打開 Email 或其他通訊軟體,把資訊帶過去。
- 讓使用者可以關掉它:在回報視窗或 App 設定裡,加一個開關「關閉搖晃回報功能」。畢竟不是每個使用者都喜歡這個功能,給他們選擇的權利很重要。
我自己是覺得,加上自動截圖的功能,投資報酬率最高。對開發團隊來說,一張圖勝過千言萬語啊。
所以,總的來說,這是一個花費不多,但卻能有效改善特定情境(特別是測試期)下使用者體驗的小功能。有時候 App 的質感,就是靠這些小細節堆出來的。有空的話,不妨在你的專案上試試看吧!
換你說說看:
你覺得「搖晃回報」這個功能,最適合用在哪種 App 上?或是有沒有哪個 App 的回饋機制讓你印象深刻?在下面留言分享你的看法吧!
