前幾天寫了篇關於 Flutter MVVM 實現的文章 [開源] 從web端開發到app端開發也許只有一個Flutter MVVM的距離,今天咱們使用它來開發一個簡單的登陸界面,體驗使用 MVVM 數據綁定在開發過程當中的便捷。 html
本篇 完整代碼git
登陸界面中包括 UserName
、Password
文本輸入框、login
按鈕、成功信息顯示文本、失敗信息顯示文本幾部分,並有以下功能點:github
UserName
、Password
任一輸入框內容長度小於3個字符時,login
按鈕爲不可用狀態web
點擊 login
按鈕,使用輸入框內容請求遠程服務,進行登陸驗證bash
請求遠程服務過程當中顯示等待狀態(將按鈕login
字樣變爲轉圈圈〜)網絡
建立Flutter項目(略〜)app
找到項目中 pubspec.yaml 文件, 並在 dependencies 部分加入包信息異步
dependencies:
mvvm: ^0.1.3+4
複製代碼
爲方便講解,本篇涉及代碼均在
main.dart
文件中,在實際項目中可自行拆分async
LoginViewModel
和登陸視圖 LoginView
,先把基礎界面搭建出來視圖模型類需從
ViewModel
類繼承。視圖類需從View
類繼承,並指定視圖模型LoginViewModel
mvvm
class LoginViewModel extends ViewModel {
}
class LoginView extends View<LoginViewModel> {
LoginView() : super(LoginViewModel());
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
margin: EdgeInsets.only(top: 100, bottom: 30),
padding: EdgeInsets.all(40),
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(height: 10),
TextField(
decoration: InputDecoration(
border: UnderlineInputBorder(),
labelText: 'UserName',
),
),
SizedBox(height: 10),
TextField(
obscureText: true,
decoration: InputDecoration(
border: UnderlineInputBorder(),
labelText: 'Password',
),
),
SizedBox(height: 10),
Text("Error info.",
style: TextStyle(color: Colors.redAccent, fontSize: 16)),
Container(
margin: EdgeInsets.only(top: 80),
width: double.infinity,
child: RaisedButton(
onPressed: () {},
child: Text("login"),
color: Colors.blueAccent,
textColor: Colors.white)),
SizedBox(height: 20),
Text("Success info.",
style: TextStyle(color: Colors.blueAccent, fontSize: 20))
])));
}
}
複製代碼
void main() => runApp(MaterialApp(home: LoginView()));
複製代碼
此刻運行後效果
UserName
、Password
任一輸入框內容長度小於3個字符時,login
按鈕爲不可用狀態
在Flutter中文本輸入框(TextField
)是經過附加一個控制器 TextEditingController
來控制其輸入輸出的。
首先咱們在 LoginViewModel
中建立兩個 TextEditingController
, 並在視圖 LoginView
中,使用 $Model
將 TextEditingController
附加到 UserName
和 Password
文本輸入框上
爲方便顯示省略了部分代碼
class LoginViewModel extends ViewModel {
final TextEditingController userNameCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController();
}
class LoginView extends View<LoginViewModel> {
LoginView() : super(LoginViewModel());
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
// ...
TextField(
controller: $Model.userNameCtrl, //這裏
decoration: InputDecoration(
border: UnderlineInputBorder(),
labelText: 'UserName',
),
),
SizedBox(height: 10),
TextField(
controller: $Model.passwordCtrl, //這裏
obscureText: true,
decoration: InputDecoration(
border: UnderlineInputBorder(),
labelText: 'Password',
),
),
// ...
])));
}
}
複製代碼
爲了 LoginView
中能監視兩個輸入框內容變化,咱們在 LoginViewModel
中添加兩個適配屬性
當輸入框內容變化時,對應的
TextEditingController
會提供變動通知,因此咱們要作的就是將它適配到咱們的綁定屬性上。在 Flutter MVVM 中已經封裝了現成的方法propertyAdaptive
(API)
class LoginViewModel extends ViewModel {
final TextEditingController userNameCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController();
LoginViewModel() {
// 使用 #userName 作爲鍵建立適配到 TextEditingController 的屬性
propertyAdaptive<String, TextEditingController>(
#userName, userNameCtrl, (v) => v.text, (a, v) => a.text = v,
initial: "");
propertyAdaptive<String, TextEditingController>(
#password, passwordCtrl, (v) => v.text, (a, v) => a.text = v,
initial: "");
}
}
複製代碼
如今咱們能夠在 LoginView
中監視兩個屬性的變化了
爲方便顯示省略了部分代碼
class LoginView extends View<LoginViewModel> {
LoginView() : super(LoginViewModel(RemoteService()));
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
// ...
Text("Error info.",
style: TextStyle(color: Colors.redAccent, fontSize: 16)),
Container(
margin: EdgeInsets.only(top: 80),
width: double.infinity,
// 使用 $.watchAnyFor 來監視 #userName, #password 屬性變化
child: $.watchAnyFor<String>([#userName, #password],
builder: (_, values, child) {
// 當任一屬性值發生變化時此方法被調用
// values 爲變化後的值集合
var userName = values.elementAt(0),
password = values.elementAt(1);
return RaisedButton(
// 根據 #userName, #password 屬性值是否符合要求
// 啓用或禁用按鈕
onPressed: userName.length > 2 && password.length > 2
? () {}
: null,
child: Text("login"),
color: Colors.blueAccent,
textColor: Colors.white);
})),
// ...
])));
}
}
複製代碼
運行查看效果
爲了能更加便於維護,咱們能夠將 LoginView
中對輸入驗證的邏輯放入 LoginViewModel
中。
class LoginViewModel extends ViewModel {
final TextEditingController userNameCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController();
LoginViewModel() {
propertyAdaptive<String, TextEditingController>(
#userName, userNameCtrl, (v) => v.text, (a, v) => a.text = v,
initial: "");
propertyAdaptive<String, TextEditingController>(
#password, passwordCtrl, (v) => v.text, (a, v) => a.text = v,
initial: "");
}
// 將 LoginView 中 userName.length > 2 && password.length > 2 邏輯
// 移到 LoginViewModel 中,方便之後變動規則
bool get inputValid =>
userNameCtrl.text.length > 2 && passwordCtrl.text.length > 2;
}
class LoginView extends View<LoginViewModel> {
LoginView() : super(LoginViewModel(RemoteService()));
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
// ...
Text("Error info.",
style: TextStyle(color: Colors.redAccent, fontSize: 16)),
Container(
margin: EdgeInsets.only(top: 80),
width: double.infinity,
// 使用 $.watchAnyFor 來監視 #userName, #password 屬性變化
child: $.watchAnyFor<String>([#userName, #password],
// $.builder0 用於生成一個無參的builder
builder: $.builder0(() => RaisedButton(
// 使用 LoginViewModel 中的 inputValid
// 啓用或禁用按鈕
onPressed: $Model.inputValid
? () {}
: null,
child: Text("login"),
color: Colors.blueAccent,
textColor: Colors.white)
))),
// ...
])));
}
}
複製代碼
點擊
login
按鈕,使用輸入框內容請求遠程服務,進行登陸驗證
建立一個模擬遠程服務類來完成登陸驗證功能,這個服務類只有一個 login 方法,當userName="tom" password="123"時即爲合法用戶,不然登陸失敗拋出錯誤信息。而且爲了模擬網絡效果,將延遲3秒返回結果
class User {
String name;
String displayName;
User(this.name, this.displayName);
}
// mock service
class RemoteService {
Future<User> login(String userName, String password) async {
return Future.delayed(Duration(seconds: 3), () {
if (userName == "tom" && password == "123")
return User(userName, "$userName cat~");
throw "mock error.";
});
}
}
複製代碼
爲了 LoginView
中能監視登陸請求變化,咱們在 LoginViewModel
中添加異步屬性,同時將模擬服務注入進來以備使用
在 Flutter MVVM 中封裝了現成的建立異步屬生方法
propertyAsync
(API),propertyAsync
並無內置在ViewModel
類中,要使用它須要LoginViewModel
withAsyncViewModelMixin
class LoginViewModel extends ViewModel with AsyncViewModelMixin {
final RemoteService _service;
final TextEditingController userNameCtrl = TextEditingController();
final TextEditingController passwordCtrl = TextEditingController();
// 注入服務
LoginViewModel(this._service) {
// 使用 #login 作爲鍵建立一個異步屬性
// 並提供一個用於獲取 Future<User> 的方法
// 咱們使用模擬服務的 login 方法,並將 userName、password 傳遞給它
propertyAsync<User>(
#login, () => _service.login(userNameCtrl.text, passwordCtrl.text));
propertyAdaptive<String, TextEditingController>(
#userName, userNameCtrl, (v) => v.text, (a, v) => a.text = v,
initial: "");
propertyAdaptive<String, TextEditingController>(
#password, passwordCtrl, (v) => v.text, (a, v) => a.text = v,
initial: "");
}
bool get inputValid =>
userNameCtrl.text.length > 2 && passwordCtrl.text.length > 2;
}
複製代碼
在 LoginView
中使用異步屬性
當咱們建立異步屬性後,除了提供基於這個屬性的綁定功能外,Flutter MVVM 還會爲咱們提供基於這個屬性的
getInvoke
(API)、invoke
(API) 和link
(API) 方法,getInvoke
會返回一個用於發起請求的方法,而invoke
則會直接發起請求,link
等同於getInvoke
, 是它的別名方法
須要注意的是,當綁定異步屬性時,Flutter MVVM 會將屬性值(請求結果)封裝成
AsyncSnapshot<TValue>
class LoginView extends View<LoginViewModel> {
// 注入服務實例
LoginView() : super(LoginViewModel(RemoteService()));
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
// ...
SizedBox(height: 10),
// $.$ifFor 來監視 #login 屬性值變化
// 當屬性值變化時使用 valueHandle 結果來控制 widget 是否顯示
// snapshot.hasError 表示請求結果中有錯誤時顯示
$.$ifFor<AsyncSnapshot>(#login,
valueHandle: (AsyncSnapshot snapshot) => snapshot.hasError,
builder: $.builder1((AsyncSnapshot snapshot) => Text(
"${snapshot.error}",
style:
TextStyle(color: Colors.redAccent, fontSize: 16)))),
Container(
margin: EdgeInsets.only(top: 80),
width: double.infinity,
child: $.watchAnyFor<String>([#userName, #password],
builder: $.builder0(() => RaisedButton(
// 使用 $Model.link 將發起異步請求方法掛接到事件
onPressed:
$Model.inputValid ? $Model.link(#login) : null,
child: Text("login"),
color: Colors.blueAccent,
textColor: Colors.white)))),
SizedBox(height: 20),
// $.$ifFor 來監視 #login 屬性值變化
// 當屬性值變化時使用 valueHandle 結果來控制 widget 是否顯示
// snapshot.hasData 表示請求正確返回數據時顯示
$.$ifFor<AsyncSnapshot<User>>(#login,
valueHandle: (AsyncSnapshot snapshot) => snapshot.hasData,
// 綁定驗證成功後的用戶顯示名
builder: $.builder1((AsyncSnapshot<User> snapshot) => Text(
"${snapshot.data?.displayName}",
style:
TextStyle(color: Colors.blueAccent, fontSize: 20))))
])));
}
}
複製代碼
運行後效果
由於模擬服務延遲了3秒,因此中間會有一個很不友好的停滯狀態,咱們接着實現對等待狀態的處理,讓它友好一點。
請求遠程服務過程當中顯示等待狀態(將按鈕
login
字樣變爲轉圈圈〜)
以前提到過,Flutter MVVM 會將異步屬性的請求結果封裝成 AsyncSnapshot<TValue>
,而 AsyncSnapshot<TValue>
中的 connectionState
已經爲咱們提供了請求過程當中的狀態變化,只要在 connectionState
爲 waiting
時, 把 login
按鈕的 child
變成轉圈圈動畫就能夠了
class LoginView extends View<LoginViewModel> {
LoginView() : super(LoginViewModel(RemoteService()));
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
// ...
Container(
margin: EdgeInsets.only(top: 80),
width: double.infinity,
child: $.watchAnyFor<String>([#userName, #password],
builder: $.builder0(() => RaisedButton(
onPressed:
$Model.inputValid ? $Model.link(#login) : null,
// 使用 $.watchFor 監視 #login 狀態變化
// waiting 時顯示轉圈圈〜
child: $.watchFor(#login,
builder: $.builder1((AsyncSnapshot snapshot) =>
snapshot.connectionState ==
ConnectionState.waiting
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
backgroundColor: Colors.white,
strokeWidth: 2,
))
: Text("login"))),
color: Colors.blueAccent,
textColor: Colors.white)))),
SizedBox(height: 20),
// ...
])));
}
}
複製代碼
運行後效果
這裏細心的小夥伴應該會注意到一個小問題,第一次登陸失敗顯示了錯誤信息,但只有當再次發起登陸請求並結果返回時,第一次登陸失敗的錯誤信息才被更新,這也是一個不太好的體驗,咱們只需在 $Model.link(#login)
處稍加改動 在每次發起請求時當即重置一下狀態。
// ...
RaisedButton(
// 使用 resetOnBefore
onPressed:
$Model.inputValid ? $Model.link(#login, resetOnBefore: true) : null,
// ...
複製代碼
運行後效果
對於服務驗證成功後跳轉頁面的場景,能夠在建立異步屬性時指定 onSuccess
方法,當異步請求成功返回結果時定製後續操做。(更多異步屬性參數可查看 API)
咱們已經基本實現了預期的登陸功能,但由於咱們在 $.watchAnyFor<String>([#userName, #password], builder: ..)
的 builder
方法內部又嵌套使用了 $.watchFor(#login, ..)
,因此這會致使一個問題是當上層的 #userName, #password
發生變化時,無論其 builder
內部監視的 #login
是否變化都會連同上層同時觸發變化 (在兩個 builder
方法中加入調試信息可查看現象),這並非咱們預期想要的結果,形成了沒必要要的性能損失。解決方法很簡單,只須要將內部嵌套的 $.watchFor(#login, ..)
移到上層 $.watchAnyFor<String>([#userName, #password], ..)
方法的 child
參數中。
class LoginView extends View<LoginViewModel> {
LoginView() : super(LoginViewModel(RemoteService()));
@override
Widget buildCore(BuildContext context) {
return Scaffold(
body: Container(
// ...
Container(
margin: EdgeInsets.only(top: 80),
width: double.infinity,
child: $.watchAnyFor<String>([#userName, #password],
builder: $.builder2((_, child) => RaisedButton(
onPressed:
$Model.inputValid ? $Model.link(#login) : null,
// 使用從外部傳入的 child
child: child,
color: Colors.blueAccent,
textColor: Colors.white)),
// 將按鈕 child 的構造移到此處
child: $.watchFor(#login,
builder: $.builder1((AsyncSnapshot snapshot) =>
snapshot.connectionState ==
ConnectionState.waiting
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
backgroundColor: Colors.white,
strokeWidth: 2,
))
: Text("login"))))),
SizedBox(height: 20),
// ...
])));
}
}
複製代碼
如今哪裏有變化只會更新哪裏,不會存在不應有的多餘更新,至此,咱們已經實現了完整的登陸功能。
文章篇幅有點長,但其實內容並很少,主要對 Flutter MVVM 的使用進行了相應的解釋說明,用數據綁定來減小咱們的邏輯代碼及工做量,提高代碼的可維護性。