Flutter 太好學了!BUG 真的太少了! issues 只有 5000 多!也就那麼億點!簡單得我都枯了!畢竟每次遇到問題,?? 都是直接去找群裡的法佬、低調、Alex 等幾位大佬(?管理,此處小聲嗶嗶)來解決,只要有大佬在,問題也就不大。雖然法佬經常說要學會看原始碼,但道理大家其實都懂,看原始碼也就圖一樂,真正有 BUG 還是得找法佬。
不多嗶嗶,單寫一篇文章,先記錄它一手。本文記錄 ?? 在 Flutter 開發中遇到的一些 BUG(as design),避免遺忘,如果正在看文章的你也遇到了,那我們們可以握個手。
容器寬高相關問題
Container 設定寬高不生效
一般是由於父級容器的 constraints
屬性引起的,在 Flutter 中,子元件的大小會被父元件的 constraints
屬性限制,例如
ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100.0, // 最小寬度為 100 畫素
minHeight: 50.0 // 最小高度為 50 畫素
),
child: Container(
height: 5.0,// 高度為 5 邏輯畫素
child: redBox
),
)
複製程式碼
上面的程式碼中,Container
元件設定高度為 5 畫素,是無法生效的,因為父級容器已經設定了最小高度為 50 畫素,所以 Container
元件的最終高度將會是 50 畫素。
當然,這肯定不是我們想要的效果,我們就想讓 Container
元件的最終高度是 5 畫素怎麼辦?其實很簡單,可以使用 UnconstraindBox
解除父級容器的 constraints
屬性對子元件大小的限制。例如:
ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100.0, // 最小寬度為 100 畫素
minHeight: 50.0 // 最小高度為 50 畫素
),
child: UnconstraintsBox(
child: Container(
height: 5.0,
child: redBox
),
),
)
複製程式碼
UnconstrainedBox
允許其子元件按照其自身的大小繪製,我們很少直接使用此元件,除非對於 Material 自帶的一些元件,如 Appbar
的 icon 被官方限制了固定的大小,利用該元件可以解除限制,而一般情況下,我們在元件外面套一層佈局類元件就可以解決需求,例如以下元件:
Row()
Column()
Align()
Center()
Flex()
Wrap()
Flow()
Stack()
複製程式碼
SignleChildScrollView 不滿一屏高度時無法撐滿全屏
其實和上面這個問題是相似的,可以使用佈局類元件解決,或者用如下方式:
Container(
alignment: Alignment.topLeft,
child: SingleChildScrollView(),
),
複製程式碼
如果你看過 Container
的原始碼你會發現其實設定 alignment
屬性,和用 Align
元件是一回事,原始碼也是使用 Align
元件,這就是個語法糖,僅此而已。
說到語法糖,其實 Center
元件也是 Align
元件的語法糖,當你不給 Align
傳遞任何引數時,使用 Center()
和使用 Align()
是一模一樣的效果,我的習慣是不管什麼情況,都是隻用Align
元件。
如何自定義 AppBar
上文提到過,Flutter 官方對 AppBar 的限制非常嚴格,連基本的高度都被寫死了,這怎麼能滿足我們專案錦鯉所提出的花式需求呢?所以我在專案中除了使用了自帶的 SliverAppBar
,其他相關的 AppBar
元件基本沒用。
自定義 AppBar
有兩種方式:
第一種方式,使用 Column
及 Expanded
元件,提供我專案中的一個簡單示例:
class VideoEditPage extends StatelessWidget {
get appBar => DecoratedBox(
decoration: BoxDecoration(
color: currentTheme.primaryColor,
boxShadow: tabBoxShadow,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: navigatorState.pop,
child: Container(
width: Screens.appBarHeight,
height: Screens.appBarHeight,
margin: EdgeInsets.only(right: setWidth(7)),
child: Icon(
IcoMoon.arrowLeft,
size: setWidth(42),
color: currentTheme.primaryColorDark,
),
),
),
Text('編輯視訊', style: titleStyle),
],
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: Routes.pushUploadCoverEditPage,
child: Container(
height: Screens.appBarHeight,
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: setWidth(19)),
child: Text('下一步', style: titleStyle),
),
),
],
),
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: <Widget>[
appBar,
Expanded(child: listView)
],
),
),
);
}
}
複製程式碼
第二種方式,使用 Stack
及 Positioned
元件,示例:
class MyApp extends StatelessWidget {
get body => Stack(
children: <Widget>[
appBar,
Positioned(
left: 0,
top: Screens.appBarHeight,
right: 0,
bottom: bottomAppBarHeight,
child: listViewBox,
),
bottomAppBar,
],
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SizedBox.expand(child: body),
),
);
}
}
複製程式碼
Container 設定 borderRadius 不生效
設定 borderRadius
有兩種做法,第一種使用 Container
等元件自帶的 borderRadius
屬性,第二種是,直接用 ClipRRect
等 clip 元件對容器進行裁剪,第二種比第一種更加暴力、消耗效能,但更有效。
例如給 TabView
的容器設定 borderRadius
,你會發現無法生效,而使用 ClipRRect
則可以解決,我的理解是 ClipRRect
會直接裁剪成圓角形狀,而 BorderRadius
的圓角外的弧形範圍是透明的,類似 css
中的 display:none
與 opaticy:0
的區別,實際具體是什麼原因,我也沒有去細究,複製貼上、能跑就行。
元素顯示層級問題
可以認為 Flutter 中 widget
佈局的層級關係是遞進的,例如 child
的層級比父 Widget
層級更高, Column
、Row
等元件的 children
中同級 widget
,誰在後面誰的層級就更高,和 Stack
其 children
的層級關係相同。
顯示隱藏的幾種做法
第一種,利用 IndexedStack
元件控制層級,上面也提到過,子元件誰在後面誰的層級就高,Flutter 中雖然沒有 z-index
這一說法,但其實原理和 css 的 z-index
是類似的,index 越大,層級越高,當然這裡的 IndexedStack
的 index
屬性是用來控制當前顯示的某一個 children,只能顯示一個。該方法常用於 APP 首頁切換底部導航。
第二種,利用 IgnorePointer
及 Opacity
元件組合隱藏 widget,可以使用 AnimationOpacity
元件達到以前 JQuery
中常用的 fadeIn
效果。
第三種,利用 Positioned
或 Transform.translate
移動到螢幕外,需要顯示時再移動回來,這種做法非常適合動畫切換,例如視訊進度條等效果。
第四種,利用 Offstage
元件,前三種都是利用視覺效果將元素隱藏起來,其實在佈局上並未發生改變,而此元件就是類似於 css 中的 display:none
,直接讓元素在佈局中隱藏,不會在佈局上繼續佔用空間。
最後一種,在 build 方法中提前判斷,不符合條件直接不渲染,或者返回空 box,這就類似於 HTML 中刪除 dom 元素,我人沒了,還顯示個?,這是最恐怖的。
GestureDetector 設定 onTap 不生效
Listener
預設的 behavior
是 HitTestBehavior.deferToChild
如果 Listener
的子元件是一個 Container
,這個 Container
不設定 decoration
的情況下,即透明背景色、無邊框,則點選 Container
時,無法觸發 down、up
等事件。
同理,GestureDetector
是對 Listener
的封裝,無法觸發 onTap
等事件也是必然的,那麼解決辦法也很簡單,有以下兩種解決辦法:
1. 給 Container 設定 decoration
2. 將 behavior 屬性設定為 opaque 或 translucent
複製程式碼
呼叫 setState 或 markNeedsBuild 後報錯
第一種報錯
setState() or markNeedsBuild() called during build
遇到此提示,一般解決思路都是利用 addPostFrameCallback
來解決,例如:
WidgetsBinding.instance.addPostFrameCallback((_){
_model.setOpacity(opacity);
});
複製程式碼
第二種報錯
setState() called after dispose()
一般定時器在 app 返回桌面後仍在呼叫 setState
或 頁面 pop 銷燬後非同步任務才完成,此時呼叫了 setState
必然會出現該提示,那麼解決辦法也很簡單,判斷生命週期再執行重構邏輯。
if (!mounted) return;
setState(() {
// do somthing
});
複製程式碼
動態更改 TabBar 的長度後 setState 報錯
其實這個問題肯定是由於使用了 SingleTickerProviderStateMixin
造成的,解決方案有兩種。
第一種是使用 DefaultTabController
來解決,這個方案比較適合大佬造輪子,因為需要自己寫 TabBar
的切換效果,非常之麻煩。
第二種方案就是我目前正在使用的,非常簡單,只需要將 SingleTickerProviderStateMixin
替換為 TickerProviderStateMixin
即可,相關程式碼如下:
class EntryPage extends StatefulWidget {
@override
createState() => _EntryPageState();
}
class _EntryPageState extends State<EntryPage> with TickerProviderStateMixin {
TabController tabController;
final tabs = <DanceSort>[
DanceSort.fromJson({"id": -1, "name": "推薦"}),
DanceSort.fromJson({"id": 0, "name": "關注"}),
];
Future<void> getTabBar() async {
final danceSorts = await EntryApi.getDanceSorts();
if (danceSorts == null) return;
tabs.addAll(danceSorts);
tabController.dispose();
tabController = TabController(length: tabs.length, vsync: this);
setState(() {});
}
@override
initState() {
getTabBar();
tabController = TabController(length: tabs.length, vsync: this);
super.initState();
}
@override
dispose() {
tabController.dispose();
super.dispose();
}
get tabBar => TabBar(
controller: tabController,
tabs: tabs.map<Tab>((v) => Tab(text: v.name)).toList(),
);
get tabBarView => TabBarView(
controller: tabController,
children: tabs.map<RecommendList>((v) => RecommendList(v.id)).toList(),
);
@override
Widget build(BuildContext context) {
return FloatingScrollView(
tabBar: tabBar,
tabBarView: tabBarView,
);
}
}
複製程式碼
在 initState
時,先初始化本地預設的 tab,通過 Api 請求到服務端的 tab 資料後,再將原 tabController
銷燬,生成一個新的 tabController
,由於使用的是 TickerProviderStateMixin
,所以並不會因為 Single
而報錯。
為了便於理解,這個例子使用的 setState
來重新構建佈局,其實完全可以使用 Provider 進行優化,我的專案也是全部使用 Provider 來進行管理的,利用 Selector
將構建範圍縮小至最小,能很大地改善重構佈局時的效能問題,例如上面 tabBar
部分可以換成:
get tabBar => Selector<HomeModel, TabController>(
selector: (context, model) => model.tabController,
builder: (context, controller, _) => TabBar(
controller: controller,
tabs: model.tabs.map<Tab>((v) => Tab(text: v.name)).toList(),
),
);
複製程式碼
鍵盤相關問題
鍵盤彈出後將佈局頂起來了,而不是遮住佈局
解決辦法:在 scafold
裡設定 resizeToAvoidBottomInset: false
,鍵盤會遮住佈局,而不是頂起佈局。
就想讓鍵盤頂起佈局,佈局卻溢位了怎麼辦?
溢位肯定是因為沒有鍵盤時,整體高度沒有一屏高,鍵盤出現了,卻超出了一屏的高度。解決辦法很簡單,首先將佈局使用 SingleChildScrolleView
之類的滾動元件包裹住,將佈局改變為可滾動的,這樣鍵盤彈出後佈局就不會溢位了。
接著可以使用 WidgetsBindingObserver
類來監聽鍵盤彈起事件,每次彈起鍵盤後會自動觸發 didChangeMetrics
鉤子,在該鉤子裡執行邏輯即可,例如將 SingleChildScrolleView
的當前位置調整至最底部,相關程式碼如下:
import 'package:flutter/material.dart';
class Demo extends StatefulWidget {
@override
createState() => _DemoState();
}
class _DemoState extends State<Demo> with WidgetsBindingObserver {
final _scrollController = ScrollController();
final _phoneController = TextEditingController();
FocusNode _phoneFocusNode = FocusNode();
FocusScopeNode _focusScopeNode;
get _phoneTextFiled => TextField(
controller: _phoneController,
focusNode: _phoneFocusNode,
keyboardType: TextInputType.phone,
maxLength: 11,
decoration: InputDecoration(
hintText: '請輸入手機號',
border: InputBorder.none,
counterText: '',
),
);
void handlePostFrame() {
if (!_phoneFocusNode.hasFocus) {
print('requestFocus');
_focusScopeNode.requestFocus(_phoneFocusNode);
}
print('jumpTo');
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
@override
void initState() {
WidgetsBinding.instance.addObserver(this);
super.initState();
}
@override
void didChangeMetrics() {
WidgetsBinding.instance.addPostFrameCallback(handlePostFrame);
super.didChangeMetrics();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
複製程式碼
鍵盤彈起和收回會引起頁面重新build
我的專案中有一個接近 1 萬行程式碼的視訊詳情頁,全部使用 Provider
進行狀態管理,如果鍵盤彈起、收回引起重新 build,就可能出現一些奇怪的 BUG,比如當前的滾動元件在螢幕中的位置發生變化。
我的解決方案是利用 showBottomSheet
方法,頁面中展示的 TextField
上蓋一層透明遮罩,使使用者無法點選,而點選遮罩時,則觸發 showBottomSheet
, push 進一個新的路由,彈起鍵盤,卻不會引起重新 build,收起鍵盤時,則會 pop 回頁面,其實視覺上一直都保持在同一頁面中,和普通的彈起鍵盤沒區別,並且效能也非常棒,相關程式碼如下:
get textField => TextField(
autofocus: true,
cursorColor: currentTheme.hoverColor,
cursorWidth: 1.0,
textInputAction: TextInputAction.done,
style: TextStyle(
color: currentTheme.primaryColorLight,
fontSize: setSp(32),
),
decoration: InputDecoration(
hintText: '發一句友善的評論來見證當下吧',
hintStyle: TextStyle(fontSize: setSp(28)),
contentPadding: EdgeInsets.symmetric(horizontal: setWidth(31)),
filled: true,
fillColor: currentTheme.primaryColorDark,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(setWidth(30)),
borderSide: BorderSide.none
),
),
onSubmitted: (value) {},
);
Widget buildTextFieldPage(BuildContext context) {
return SizedBox.expand(
child: Stack(
alignment: Alignment.bottomLeft,
children: <Widget>[
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.pop(context),
child: Container(color: Colors.black.withOpacity(.5)),
),
),
buildInput(),
],
),
);
}
buildInput({hasTextField = true}) {
Widget child;
child = hasTextField
? Container(
decoration: BoxDecoration(
color: currentTheme.backgroundColor,
borderRadius: BorderRadius.circular(setWidth(31)),
),
child: textField,
)
: GestureDetector(
onTap: () {
showBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: buildTextFieldPage,
);
},
child: Container(
decoration: BoxDecoration(
color: currentTheme.backgroundColor,
borderRadius: BorderRadius.circular(setWidth(31)),
),
),
);
return Container(
height: setWidth(103),
padding: EdgeInsets.symmetric(
vertical: setWidth(20),
horizontal: setWidth(25),
),
decoration: BoxDecoration(
border: Border(top: commentDivider),
color: currentTheme.primaryColor,
),
child: Row(
children: <Widget>[
Expanded(child: child),
Container(
width: setWidth(66),
padding: EdgeInsets.only(left: setWidth(25)),
alignment: Alignment.center,
child: Icon(
IcoMoon.send,
color: currentTheme.hoverColor.withOpacity(.5),
size: setWidth(42),
),
),
],
),
);
}
複製程式碼
相關效果如下:
TextField 如何根據內容自適應高度
TextField
在最大行數為一行時,可以直接通過父級 Container
限制 TextField
的高度,不需要設定額外屬性,但是在多行高度時,這種做法就不太靈驗。
可以看到上圖中評論框會根據輸出的內容自動調整高度,並且限制了最大行數為 4,而這種需求又是特別常見的,我是如何做到的呢?
首先,需要設定 TextField
的 maxLine
屬性為 null
,這樣 TextField
就會根據內容自動調整高度
接著,設定 decoration: InputDecoration(isDense:true)
,這樣 TextField
將允許我們限制 TexField
的最大高度
最後,利用父級容器限制 TextField
的最大高度,例如
Container(
constraints: BoxConstraints(maxHeight: 200),
child: TextField(),
)
複製程式碼
通過以上設定後, TextField
的高度就是根據內容自動適應,並且會被限制最大高度,但是還需要說明一點,如何保證最大高度剛好是 4 行的高度?
由於 TextField
沒有屬性是用來限制每行高度的,所以這就需要我們自己計算 contentPadding
了,例如:
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 20),
}
複製程式碼
TextField 設定 border 不生效
TextField 的 border
有如下 3 種,需要針對性地設定,只設定一個是無法生效的:
decoration: InputDecoration(border enabledBorder focusBorder)
複製程式碼
ps:設定 maxLength
屬性後,decoration
裡需要設定 counterText: ''
,否則預設會附帶一個統計字數的樣式。
路由跳轉相關問題
push、pop 常見需求
例如瀏覽記錄中有如下 4 個頁面,當前頁面為 d
:
a->b->c->d
複製程式碼
在當前頁面使用 Navigator.popUtil(context, ModalRoute.withName('a'))
,可以直接返回至 a
頁面,並銷燬 b
、c
頁面。
在當前頁面使用 Navigator.pushNamedAndRemoveUntil(context, 'e', (route) => false)
,可以進入 e
頁面之前,銷燬所有歷史記錄,即 e
頁面變成第一頁,e
頁面裡無法繼續 pop
返回上一頁。
如何在 initState 階段就能使用 context
Flutter 有一些需要使用 context
的方法,例如 Theme.of(context)
、Navigator.of(context)
等等,一般情況下,我們需要在 build 方法中才能拿到 context
,如果我們在定義類的屬性時就直接使用 Theme.of(context)
顯然是沒辦法做到的,編輯器都會直接提示語法錯誤。
解決辦法也很簡單,首先建立一個檔案 constants.dart
用於儲存專案的全域性變數,在 Contants
類中宣告一個 navigatorKey
:
class Constants {
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
}
複製程式碼
接著在 MaterailApp
下掛載 navigatorKey
:
MaterialApp(navigatorKey: Constants.navigatorKey)
複製程式碼
最後在 constants.dart
中宣告如下 3 個 getter
:
NavigatorState get navigatorState => Constants.navigatorKey.currentState;
BuildContext get currentContext => navigatorState.context;
ThemeData get currentTheme => Theme.of(currentContext);
複製程式碼
由於所有元件都是掛載在 MaterialApp
下的,所以我們在任何一個元件中引入了 constants.dart
,就可以使用這 3 個 getter
,用法如下:
class LoginPage extends StatelessWidget {
final box = GestureDetector(
onTap: navigatorState.pop,
child: Text('返回上一頁'),
);
final userModel = Provider.of<UserModel>(currentContext, listen: false);
final primaryColor = currentTheme.primaryColor;
}
複製程式碼
上面的程式碼等價於:
class LoginPage extends StatelessWidget {
Widget box;
UserModel userModel;
Color primaryColor;
build(context) {
box = GestureDetector(
onTap: Navigator.of(context).pop,
child: Text('返回上一頁'),
);
userModel = Provider.of<UserModel>(context, listen: false);
primaryColor = Theme.of(context).primaryColor;
}
}
複製程式碼
網路請求相關
如何封裝 Dio
網路請求大家基本都是使用 Dio
,我在專案上手後遇到的第一個問題就是如何對 Dio
進行封裝,下面提供一下我的做法(僅供參考):
(1)lib
目錄下建立一個 api
目錄,用於存放網路請求相關的檔案。
(2)api
目錄下建立一個 api_path.dart
用於存放專案介面的地址配置資訊,內容大致如下:
const baseUrl = 'http://app.lcgod.com/';
const version = '1.0';
class ApiPath {
static const messages = 'messages';
static const loginWithSMS = 'login/sms';
static const loginWithPassword = 'login/password';
static const loginWithWeChat = 'login/wechat';
static const loginWithQQ = 'login/qq';
static const loginWithWeibo = 'login/weibo';
static const videos = 'videos';
static const users = 'users';
static followUser(int id) => '$users/$id/follow_users';
static videoCollect(int id) => '$videos/$id/collect_users';
static videoLike(int id) => '$videos/$id/like_users';
static videoShare(int id) => '$videos/$id/share_users';
static videoComments(int id) => '$videos/$id/comments';
}
複製程式碼
(3)api
目錄下建立一個 api.dart
作為 Dio
的基類,內容大致如下:
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:http/io_client.dart';
import 'package:http_client_helper/http_client_helper.dart';
import 'api_path.dart';
export 'login_api.dart';
export 'user_api.dart';
class Api {
static Dio _dio;
static void init() {
HttpClient http = HttpClient();
http.badCertificateCallback = (cert, host, port) => true;
IOClient client = IOClient(http);
HttpClientHelper().set(client);
_dio = Dio()
..options.baseUrl = baseUrl
..options.headers['accept'] = 'application/vnd.vhiphop.v$version+json'
..interceptors.add(InterceptorsWrapper(onRequest: _handleRequest));
}
static RequestOptions _handleRequest(RequestOptions options) {
String fullPath = options.baseUrl + options.path;
if (options.method == 'GET' && options.queryParameters.length > 0) {
List params = [];
options.queryParameters.forEach((k, v) => params.add('$k=$v'));
fullPath += '?' + params.join('&').toString();
print(fullPath);
}
final time = DateTime.now().millisecondsSinceEpoch ~/ 1000;
options.headers.addAll({
'x-date': '$time',
'x-token': '123456',
'x-user-id': Constants.user.id == null ? '' : '${Constants.user.id}',
'x-user-token': Constants.user.token ?? '',
});
return options;
}
static final _fetchTypes = <String, Function>{
'post': _dio.post,
'put': _dio.put,
'patch': _dio.patch,
'delete': _dio.delete,
'head': _dio.head,
};
static Future<dynamic> head(url, {Map<String, dynamic> data}) async {
return await _fetch('head', url, data);
}
static Future<dynamic> get(url, {Map<String, dynamic> data}) async {
return await _fetch('get', url, data);
}
static Future<dynamic> post(url, {Map<String, dynamic> data}) async {
return await _fetch('post', url, data);
}
static Future<dynamic> put(url, {Map<String, dynamic> data}) async {
return await _fetch('put', url, data);
}
static Future<dynamic> patch(url, {Map<String, dynamic> data}) async {
return await _fetch('patch', url, data);
}
static Future<dynamic> delete(url, {Map<String, dynamic> data}) async {
return await _fetch('delete', url, data);
}
static Future<dynamic> _fetch(method, url, data) async {
try {
final Response response = method == 'get'
? await _dio.get(url, queryParameters: data)
: await _fetchTypes[method](url, data: data);
return response.data;
} catch (e) {
final error = (e is DioError
&& e.response != null
&& e.response.statusCode == 403)
? e.response.data
: {"message": "伺服器網路繁忙,請稍後再試", "status_code": 1001};
showTip(error['message']);
throw error;
}
}
}
複製程式碼
(4)api
目錄下建立具體請求邏輯的檔案,例如 login_api.dart
,用於登入邏輯的介面邏輯放在該檔案中:
import 'package:vhiphop/constants/constants.dart';
import 'api.dart';
import 'api_path.dart';
class LoginApi {
static Future<dynamic> loginWithPassword({phone, password}) async {
print('開始手機密碼登入');
var isError = false;
final user = await Api.post(ApiPath.loginWithPassword, data: {
'phone': phone,
'password': password,
}).catchError((_) => isError = true);
return isError ? true : User.fromJson(user);
}
static Future<dynamic> loginWithWeChat(code) async {
print('開始微信授權登入');
var isError = false;
final user = await Api.post(ApiPath.loginWithWeChat, data: {
'code': code,
}).catchError((_) => isError = true);
return isError ? true : User.fromJson(user);
}
static Future<dynamic> checkCodeAndGetToken({
String phone,
String code
}) async {
print('開始校驗驗證碼並獲取token');
var isError = false;
final user = await Api.get(ApiPath.messages, data: {
'phone': phone,
'code': code,
}).catchError((_) => isError = true);
return isError ? true : User.fromJson(user);
}
}
複製程式碼
(5)呼叫示例:
Future<void> _handleEnterButtonTap() async {
if (_isLogging) return;
if (_code < 1 || _code > 999999) return showTip('驗證碼錯誤');
_isLogging = true;
showLoading('正在登入');
final user = await LoginApi.loginWithSMS(
phone: _phone,
code: _code,
nation: _nation,
);
_isLogging = false;
if (user == true) return;
dismissAllToast();
Constants.user = user;
Constants.saveUser();
Routes.pop();
}
複製程式碼
補充:
- 所有介面請求類都會由
api.dart
匯出,再由constants.dart
匯出,那麼專案中所有檔案只需要引入一個constants.dart
就可以拿到各個請求類。 - 所有網路請求遇到錯誤都會統一進行處理,例如我的程式碼中是統一使用
OkToast
彈出提示框,提示具體資訊。
Dio 請求的 content-type
問題
使用 Dio 進行 HTTP 請求時,請求頭 content-type
的預設值是
application/json; charset=utf-8
複製程式碼
如果返回頭的 content-type
是
application/json
複製程式碼
Dio 將自動解析返回 json 資料為 Dart 相應的資料型別,而不需要手動地呼叫 jsonDecode
方法,所以客戶端、服務端的統一使用 application/json
作為 content-type
,他好我也好。
Android 打包後無法進行網路請求
在我第一次使用 Flutter 打包專案時遇到了這個問題,最後發現是沒有網路請求的許可權,類似的,儲存讀取本地檔案時可能也會有類似問題,這種問題設定許可權就可以解決了。
在 android/app/src/profile/AndroidManifest.xml
中
以及 android/app/src/main/AndroidManifest.xml
兩個檔案的 manifest
標籤內新增如下子標籤即可:
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
複製程式碼
如果需要動態申請許可權,可以使用 permission_handler
外掛。
Mac 環境 build 時的錯誤
提示如下:
Automatically assigning platform iOS
with version 9.0
on target Runner
because no platform was specified. Please specify a platform for this target in your Podfile.
解決辦法是:刪除 pod
檔案中 platform
前的 #
因為沒有做過原生開發,所以對於這種 build 問題真的是一臉茫然,最開始遇到過幾次類似錯誤,我通過網上搜尋答案、群裡問大佬來解決,非常之麻煩。所以後來我在 Mac 環境 build 產生錯誤時,都是直接重建專案,把邏輯程式碼複製進新專案裡,再重新 build 就不會發生各種亂七八糟看不懂的錯誤了,效率也快。
TabBar 切換導致 PageView、ListView 等滾動元件高度位置發生改變
解決辦法:給滾動元件加上 key
屬性,用於儲存位置資訊,例如: key: PageStorageKey(1)
其實一般的 ListView
還無法滿足我們日常開發中各種花式的需求,推薦使用法佬的 NestedScrollView
法佬已經給我們解決了很多奇怪的 BUG,還要什麼自行車?
如何監聽 App 返回桌面事件
我需要當 app 返回桌面時暫停視訊的播放,從桌面返回 app 後再繼續播放,解決方案如下:
class _DemoState extends State<Demo> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
print('app lifecycle state: $state');
if (state == AppLifecycleState.inactive) {
_playerModel.pausePlayer();
} else if (state == AppLifecycleState.resumed) {
if (_homeModel.isFindPage) _playerModel.startPlayer();
}
super.didChangeAppLifecycleState(state);
}
}
複製程式碼
WidgetsBindingObserver 這個類我經常使用,例如監聽鍵盤彈起事件也會用到這個類。
對於類中的屬性和方法的定義規範的一些建議
-
不引用其他屬性的成員,定義為屬性
-
引用其他屬性,且不接收引數的成員,定義為getter
-
引用其他屬性,且接受引數的成員,定義為function
全屏相關設定
強制豎屏:
void initState() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown
]);
super.initState();
}
複製程式碼
強制橫屏:
initState() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
super.initState();
}
複製程式碼
Transform 3D 轉換
推薦使用 Transform
元件來完成動畫效果,例如 Transform.translate
和 Transform.scale
可以完成位置、縮放的變化, Transform.rotate
可以完成旋轉角度的變化。
Transform.rotate
和 RotateBox
都可以完成旋轉功能,他們之間有什麼區別?
使用 RotateBox
渲染 widget 是在 layout 階段,渲染完畢後就會佔用實際位置,而 Transform
元件則是在 layout 之後的繪製階段, Transform
只是一個視覺效果,實際所佔空間大小是 transform 變化之前所佔用的空間大小,所以重新渲染 Transform.rotate
元件比重新渲染 RotateBox
開銷更小。
Flutter 的 Transform
元件的這個特性和 CSS 的 transform
屬性非常相似,都可以用來提升動畫效能。
不過做視訊全屏功能時,可以用 IndexedStack
+ RotateBox
替代 push 一個橫屏的路由的做法,RotateBox
它會使容器填充全屏,而 IndexedStack
可以控制是否顯示全屏,這裡如果使用 Transform
則無法填充全屏,因為容器的寬高在 layout 時就已經確定了,所以只能使用 RotateBox
。
視訊映象翻轉
我在專案中不僅使用 RotatedBox 完成視訊全屏功能,還利用了 Transform
來完成映象翻轉功能,寫法如下:
Selector<VideoModel, bool>(
selector: (context, model) => model.isMirror,
builder: (context, isMirror, child) => Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..setEntry(3, 2, 0.006)..rotateY(isMirror ? math.pi : 0),
child: child,
),
child: FijkView(
player: model.player,
color: Colors.black,
panelBuilder: (player, context, size, pos) => emptyBox,
),
)
複製程式碼
原理很簡單,FijkView 是 fijkplayer 提供的視訊容器,我將視訊容器以中心位置為圓心,沿 Y 軸做一個 180 度的旋轉,即可滿足需求。
setEntry
用於設定透視,否則將無法看到 Y 軸及 X 軸的立體轉換效果
rotateY
則與 css 中的 rotateY
是相同含義,即沿 Y 軸旋轉。在 css 中可以設定 transform: rotateY(180deg)
來達到相同的效果。
狀態列相關設定
隱藏狀態列:
import 'package:flutter/services.dart';
void toggleFullscreen() {
_isFullscreen = !_isFullscreen;
_isFullscreen
? SystemChrome.setEnabledSystemUIOverlays([])
: SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}
複製程式碼
改變狀態列顏色,則需要使用外掛:flutter_statusbarcolor
,下面是用法示例:
// 改變狀態列背景顏色,預設改變為透明
Future<void> changeStatusColor({Color color: Colors.transparent}) async {
try {
await FlutterStatusbarcolor.setStatusBarColor(
color,
animate: true,
);
FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
FlutterStatusbarcolor.setNavigationBarWhiteForeground(true);
} on PlatformException catch (e) {
debugPrint(e.toString());
}
}
複製程式碼
下面介紹一個用法,我的 home 頁使用 indexStack
元件包含了 4 個 tab 頁,每次更改 tab 會改變 currentHomeTab
的值,但不會觸發重新 build,而由於路由 push
或 pop
又會觸發重新 build,所以如果需要當進入 home 頁的 發現 tab 頁
時改變為黑色狀態列,則可以用下面這種做法:
// 在發現頁的 build 方法裡進行判斷
@override
Widget build(BuildContext context) {
if (ModalRoute.of(context).isCurrent && currentHomeTab == '發現') {
changeStatusColor(color: Colors.black);
}
}
複製程式碼
fijkplayer 秒開、進度跳轉等優化
fijkplayer 預設情況下,進度跳轉、播放可能會有效能問題,針對這些問題,可以進行以下優化:
_player.setDataSource(_video.src);
await _player.applyOptions(
FijkOption()
..setFormatOption('flush_packets', 1)
..setFormatOption('analyzemaxduration', 100)
..setFormatOption('analyzeduration', 1)
..setCodecOption('skip_loop_filter', 48)
..setPlayerOption('start-on-prepared', 1)
..setPlayerOption('packet-buffering', 0)
..setPlayerOption('framedrop', 1)
..setPlayerOption('enable-accurate-seek', 1)
..setPlayerOption('find_stream_info', 0)
..setPlayerOption('render-wait-start', 1)
);
await _player.prepareAsync();
複製程式碼
參考連結:
IjkPlayer 播放器秒開優化以及常用 Option 設定
LayoutBuilder 相關的實踐
如何實現微信朋友圈、嗶哩嗶哩評論的多行文字收起、展開功能
我寫了下面這個工具類,簡單、好用得我都枯了,原理是利用先 LayoutBuilder
判斷是否超出指定的行數,如果超出則返回 Column
,如果未超出則返回原 widget
import 'package:flutter/material.dart';
class ExpandableText extends StatefulWidget {
final String text;
final int maxLines;
final TextStyle style;
final bool expand;
final TextStyle markerStyle;
final String atName;
const ExpandableText(this.text, {
Key key,
this.maxLines,
this.style,
this.markerStyle,
this.expand = false,
this.atName = '',
}) : super(key: key);
@override
createState() => _ExpandableTextState();
}
class _ExpandableTextState extends State<ExpandableText> {
bool expand;
TextStyle style;
int maxLines;
@override
void initState() {
expand = widget.expand;
style = widget.style;
maxLines = widget.maxLines;
super.initState();
}
Widget buildOrdinaryText() {
final text = widget.text;
return LayoutBuilder(builder: (_, size) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: maxLines,
textDirection: TextDirection.ltr,
);
tp.layout(maxWidth: size.maxWidth);
if (!tp.didExceedMaxLines) return Text(text, style: style);
return Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(text, maxLines: expand ? null : widget.maxLines, style: style),
GestureDetector(
onTap: () {
expand = !expand;
(context as Element).markNeedsBuild();
},
child: Text(
expand ? '收起' : '展開',
style: widget.markerStyle,
),
),
],
),
);
});
}
Widget buildAtText() {
return LayoutBuilder(builder: (_, size) {
final tp = TextPainter(
text: TextSpan(text: '回覆 @${widget.text}:', style: style),
maxLines: maxLines,
textDirection: TextDirection.ltr,
);
tp.layout(maxWidth: size.maxWidth);
if (!tp.didExceedMaxLines) return Text.rich(
TextSpan(
children: [
TextSpan(text: '回覆 '),
TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
TextSpan(text: ':${widget.text}'),
],
),
style: style,
);
return Builder(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text.rich(
TextSpan(
children: [
TextSpan(text: '回覆 '),
TextSpan(text: '@${widget.atName}', style: widget.markerStyle),
TextSpan(text: ':${widget.text}'),
],
),
maxLines: expand ? null : widget.maxLines,
style: style,
),
GestureDetector(
onTap: () {
expand = !expand;
(context as Element).markNeedsBuild();
},
child: Text(
expand ? '收起' : '展開',
style: widget.markerStyle,
),
),
],
),
);
});
}
@override
build(context) => widget.atName == '' ? buildOrdinaryText() : buildAtText();
}
複製程式碼
呼叫方法如下:
Container(
padding: EdgeInsets.only(top: setWidth(6), bottom: setWidth(11)),
alignment: Alignment.centerLeft,
child: ExpandableText(
reply.content,
maxLines: 4,
style: commentTextStyle,
markerStyle: commentMarkerStyle,
atName: reply.isDirect > 0 ? '' : reply.pNickname,
),
),
複製程式碼
相關效果如下:
監聽父級 widget 的實際寬高資訊
LayoutBuilder 的作用非常大,可以用它來監聽某個widget的寬高資訊,我在專案中遇到了 一個需求,需要根據某個 widget 的高度來彈出 BottomSheet,而這個 widget 的高度是可以滑動改變的,那麼 LayoutBuilder
就派上用場了,做法如下:
需要監聽的 widget
是 Body()
元件,給 Body() 元件套上一個 Stack
get body => Stack(
children: <Widget>[
Body(),
BodyLayout(model),
],
);
複製程式碼
然後用 BodyLayout
元件來監聽:
import 'package:flutter/material.dart';
import 'package:vhiphop/provider/video/video_model.dart';
class BodyLayout extends StatelessWidget {
final VideoModel model;
BodyLayout(this.model);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, BoxConstraints constraints) {
model.bottomSheetDy = constraints.maxHeight;
return emptyBox;
});
}
}
複製程式碼
當 Body()
元件高度發生變化時,會觸發 LayoutBuilder
的 builder
回撥函式,在此函式中將高度資訊傳遞給 model
,那麼每次彈出 BottomSheet
之前,我就可以從 model 中拿到高度,以設定 BottomSheet 的高度。
底部彈出動畫的兩種實現方式
這種動畫在 App 中是很常見的效果,例如 App 分享功能,點選分享按鈕後,會從頁面底部彈出分享元件。
第一種,利用 showModalBottomSheet
,相關實現程式碼如下:
void showShareBottomSheet() {
showModalBottomSheet(
elevation: 0,
backgroundColor: currentTheme.highlightColor,
context: context,
builder: (context) => Container(
width: Screens.width,
decoration: BoxDecoration(color: currentTheme.primaryColor),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
alignment: Alignment.bottomLeft,
height: setWidth(59),
padding: EdgeInsets.only(left: setWidth(42)),
child: Text(
'分享',
style: TextStyle(
fontSize: setSp(32),
color: currentTheme.highlightColor,
),
),
),
Container(
height: setWidth(206),
padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
alignment: Alignment.topLeft,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: setWidth(.7),
color: currentTheme.dividerColor,
),
),
),
child: Row(
children: <Widget>[
shareIconOfQQ,
shareIconOfQQZone,
shareIconOfWeChat,
shareIconOfWeChatMoments,
shareIconOfMicroBlog,
],
),
),
Container(
height: setWidth(206),
padding: EdgeInsets.only(top: setWidth(33), left: setWidth(33)),
alignment: Alignment.topLeft,
child: Row(
children: <Widget>[
shareIconOfLink,
],
),
),
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Container(
width: Screens.width,
height: setWidth(125),
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
width: setWidth(10),
color: currentTheme.backgroundColor,
),
),
),
child: Text(
'取消',
style: TextStyle(
fontSize: setSp(36),
color: currentTheme.highlightColor,
),
),
),
),
],
),
),
);
}
複製程式碼
使用 translate 實現
我在專案中使用 showModalBottomSheet
時發現動畫有點卡頓,可能是測試手機不行,只花了 1000 大洋,但我們是個倔強窮人,非要找一種效能更好的方式,那就是 translate
了。
這種方法比 showModalBottomSheet
動畫效能更高,在我 1000 大洋的測試機 debug 模式下都非常地絲滑流暢,只是程式碼實現更復雜一點,並且需要依賴 Provider 來更新,我比較喜歡這種方式。
整個頁面都使用 Stack
構建,而 bottomSheet 與遮罩 box 則使用 Positioned
定位至頁面底部:
get body => Stack(
children: <Widget>[
page,
Positioned(
left: 0,
bottom: 0,
right: 0,
child: bottomSheetBox,
),
Positioned(
left: 0,
top: 0,
right: 0,
bottom: shareBottomSheetHeight,
child: bottomSheetBoxMask,
),
],
);
複製程式碼
接著使用我定義的一個工具類,名字叫 AnimatedTranslateBox
,我發現 Animated 家族有各種動畫元件,比如 AnimatedPadding
、AnimatedPositioned
等等,唯獨沒有 Translate
,不知道官方是什麼意思,可能他們覺得 Positioned
來調整位置就夠用了叭,可是 translate
動畫效能更高,它不香嗎?沒關係,我們自己造了一個,程式碼如下:
import 'package:flutter/material.dart';
class AnimatedTranslateBox extends StatefulWidget {
AnimatedTranslateBox({
Key key,
this.dx,
this.dy,
this.child,
this.curve = Curves.linear,
this.duration = const Duration(milliseconds: 200),
this.reverseDuration,
});
final double dx;
final double dy;
final Widget child;
final Duration duration;
final Curve curve;
final Duration reverseDuration;
@override
createState() => _AnimatedTranslateBoxState();
}
class _AnimatedTranslateBoxState extends State<AnimatedTranslateBox>
with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
Tween<double> tween;
void _updateCurve() {
animation = widget.curve == null
? controller
: CurvedAnimation(parent: controller, curve: widget.curve);
}
@override
void initState() {
super.initState();
controller = AnimationController(
duration: widget.duration,
reverseDuration: widget.reverseDuration,
vsync: this,
);
tween = Tween<double>(begin: widget.dx ?? widget.dy);
_updateCurve();
}
@override
void didUpdateWidget(AnimatedTranslateBox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.curve != oldWidget.curve) _updateCurve();
controller
..duration = widget.duration
..reverseDuration = widget.reverseDuration;
if ((widget.dx ?? widget.dy) != (tween.end ?? tween.begin)) {
tween
..begin = tween.evaluate(animation)
..end = widget.dx ?? widget.dy;
controller
..value = 0.0
..forward();
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
build(context) => AnimatedBuilder(
animation: animation,
builder: (context, child) => widget.dx == null
? Transform.translate(
offset: Offset(0, tween.animate(animation).value),
child: child,
)
: Transform.translate(
offset: Offset(tween.animate(animation).value, 0),
child: child,
),
child: widget.child,
);
}
複製程式碼
呼叫很簡單,使用 Selector
依賴 model 中的布林值,用於控制顯示隱藏:
get bottomSheetBox => Selector<VideoModel, bool>(
selector: (context, model) => model.showBottomSheet,
builder: (context, show, child) => AnimatedOpacity(
opacity: show ? 1 : 0,
curve: show ? Curves.easeOut : Curves.easeIn,
duration: bottomSheetDuration,
child: AnimatedTranslateBox(
dy: show ? 0 : bottomSheetHeight,
curve: show ? Curves.easeOut : Curves.easeIn,
duration: bottomSheetDuration,
child: child,
),
),
child: Container(
height: bottomSheetHeight,
child: bottomSheet,
),
);
複製程式碼
每當 dx
或 dy
的值發生改變,AnimatedTranslateBox
的 child 就會根據 dx
或 dy
的值進行 y 軸 或 x 軸的移動動畫。
相關的效果如下:
Provider 呼叫問題
我發現如果在 MaterialApp
下全域性掛載了 Provider ,則在 Home 頁初始化完成前,是無法使 Provider 的,例如:
class MyApp extends StatelessWidget {
final _userModel = UserModel();
final _homeModel = HomeModel();
Widget build(BuildContext context) {
return OKToast(
dismissOtherOnShow: true,
child: MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _userModel),
ChangeNotifierProvider.value(value: _homeModel),
],
child: Selector<ThemeModel, ThemeData>(
selector: (context, model) => model.theme,
builder: (context, theme, child) => MaterialApp(
navigatorKey: Constants.navigatorKey,
debugShowCheckedModeBanner: false,
theme: theme,
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
},
),
),
),
);
}
}
複製程式碼
上面的程式碼宣告瞭 MultiProvider
,如果在首頁做如下呼叫:
@override
initState() {
_model = Provider.of<HomeModel>(context);
_userModel = Provider.of<UserModel>(context);
super.initState();
}
複製程式碼
則會報錯:
I/flutter ( 8380): ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
I/flutter ( 8380): The following assertion was thrown building Builder:
I/flutter ( 8380): dependOnInheritedWidgetOfExactType<_DefaultInheritedProviderScope<HomeModel>>() or
I/flutter ( 8380): dependOnInheritedElement() was called before _HomePageState.initState() completed.
I/flutter ( 8380): When an inherited widget changes, for example if the value of Theme.of() changes, its dependent
I/flutter ( 8380): widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor
I/flutter ( 8380): or an initState() method, then the rebuilt dependent widget will not reflect the changes in the
I/flutter ( 8380): inherited widget.
I/flutter ( 8380): Typically references to inherited widgets should occur in widget build() methods. Alternatively,
I/flutter ( 8380): initialization based on inherited widgets can be placed in the didChangeDependencies method, which
I/flutter ( 8380): is called after initState and whenever the dependencies change thereafter.
複製程式碼
提示 initState
必須呼叫完成,才能使用 Provider.of
來獲取祖先節點的 model,非要使用怎麼辦?辦法也很簡單, of
方法有一個屬性值 listen
,預設值為 true
,將此值設定為 false
則不會建立與 Provider 的依賴關係,其實我在 Provider 的手冊中也發現,建議在 initState
方法中呼叫 of
時,將 listen
設定為 false
:
@override
initState() {
_userModel = Provider.of<UserModel>(context, listen: false);
_model = Provider.of<HomeModel>(context, listen: false);
super.initState();
}
複製程式碼
如何實現網易雲音樂、QQ音樂播放頁面的背景圖片模糊效果
分析一下,其實這種效果特別簡單,首先放大背景圖片,其次對圖片進行高斯模糊,直接上程式碼:
import 'package:flutter/material.dart';
import 'dart:ui';
main() => runApp(MyApp());
class MyApp extends StatelessWidget {
final image = Image.asset(
'assets/images/test.jpg',
fit: BoxFit.cover,
width: 200,
height: 200,
);
get blurImage => ClipRRect(
child: Stack(
children: <Widget>[
Transform.scale(
scale: 1.5,
child: image,
),
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: Container(
width: 200,
height: 200,
alignment: Alignment.center,
color: Colors.black.withOpacity(.3),
child: Text(
'1 個內容',
style: TextStyle(
fontSize: 24,
color: Colors.white,
),
),
),
),
],
),
);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo app',
theme: ThemeData(primarySwatch: Colors.blue),
home: Scaffold(
appBar: AppBar(title: Text('blur image demo')),
body: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
margin: EdgeInsets.only(bottom: 30),
child: image,
),
blurImage,
],
),
],
)
),
);
}
}
複製程式碼
這個效果其實沒什麼難度,主要的知識點在於 BackdropFilter
元件預設的模糊效果是全屏的,必須使用 ClipRRect
進行裁剪,而且 Transform
的幾個命名建構函式,如 Transform.translate
帶來的效果是在繪製階段發生的,會超出 widget
實際佔用的空間,也需要使用 ClipRRect
進行裁剪,最後的效果圖如下: