Flutterで本格的なアプリを開発していると自作のStatefullWidgetに値を渡したいことが出てきます。
ある程度Flutterに慣れていると次のような実装方法にしようと考えるのではないでしょうか。
- StatefullWidgetに直接値は渡せないぽいなぁ
- そうだStatefullWidgetをインスタンス化しているbuildメソッドを呼ぶために、親でsetStateを呼び出してbuildメソッドをコールしてもらおう
- そうすればbuildメソッド内でStatefullWidgetのコンストラクタが呼ばれて再生成されるから設定したい値が反映されるはずだ
そう考えて実装したのが次のコードです。
EmployeeEditorというのが、今回取り上げる値を設定可能にしたいWidgetです。
// 従業員Entity
class Employee {
int id;
String name;
Employee(this.id, this.name);
}
// Scaffold
class SampleScaffoldWidget extends StatefulWidget {
@override
State createState() => SampleScaffoldWidgetState();
}
// ScaffoldのState
class SampleScaffoldWidgetState extends State<SampleScaffoldWidget> {
List<Employee> employees = List();
Employee selectedEmployee;
@override
Widget build(BuildContext context) {
print("SampleScaffoldWidgetState#build");
return Scaffold(
appBar: AppBar(
title: Text("Employee Editor"),
),
body: Column(
children: <Widget>[
Flexible(
child: ListView.builder(
itemCount: employees.length,
itemBuilder: (context, i) {
Employee employee = employees[i];
return ListTile(
title: Text(employee.name),
onTap: () {
setState(() {
print("select:${employee.name}");
selectedEmployee = employee;
});
},
);
}),
),
EmployeeEditor(
employee: selectedEmployee,
onSubmit: (Employee _employee) {
print("onSubmit:${_employee.name}");
employees.asMap().forEach((i, employee) {
if(_employee.id == employee.id){
setState(() {
employees[i] = _employee;
});
}
});
},
)
],
));
}
@override
void initState() {
employees.add(Employee(1, "Alice"));
employees.add(Employee(2, "Bob"));
selectedEmployee = employees[0];
}
}
// 従業員名を編集するためのWidget
// コンストラクタで編集対象のEmployeeを渡す
class EmployeeEditor extends StatefulWidget {
Employee employee;
EditEmployeeCallback onSubmit;
EmployeeEditor({this.employee, this.onSubmit}) {
print("EmployeeEditor#constructor");
}
@override
State createState() {
print("EmployeeEditor#createState");
return EmployeeEditorState(employee: this.employee);
}
}
// 従業員名を編集完了されたときのCallback
typedef void EditEmployeeCallback(Employee employee);
// 従業員名を編集するためのWidgetのState
class EmployeeEditorState extends State<EmployeeEditor> {
Employee employee;
EmployeeEditorState({this.employee}) {
print("EmployeeEditorState#constructor");
_controller = TextEditingController(text: employee.name);
}
TextEditingController _controller;
@override
Widget build(BuildContext context) {
print("EmployeeEditorState#build");
return Row(
children: <Widget>[
new Flexible(
child: Container(
margin: EdgeInsets.only(left: 8.0, right: 8.0),
child: TextField(
autofocus: true,
controller: _controller,
decoration: new InputDecoration.collapsed(hintText: "Name"),
),
),
),
Container(
child: FlatButton(
onPressed: () {
employee.name = _controller.text;
widget.onSubmit(employee);
},
child: Text("Done")),
)
],
);
}
}
ListTileをクリックすると、クリックされたEmployeeがselectedEmployeeにセットされるので、SampleScaffoldWidgetのbuildが実行されEmployeeEditorのインスタンが再生成されるので、
エディターに選択されたEmployeeがセットされると考えたわけです。
しかしこのプログラムを実行すると次のような動作になりました。
AliceをAlice2に編集すると意図したとおり、リストの名前も変更されます。
次にBobをクリックしたのですが、TextFieldに名前が反映されないという状況です。
ログです。
Bobをクリックしたあとは予想していたとおりEmployeeEditorのコンストラクタが呼び出され、EmployeeEditorState#buildも実行されています。
しかしどうやらEmployeeEditorStateが保持しているemployeeはAliceのインスタンスのようです。
Performing hot restart...
Restarted app in 1,657ms.
I/flutter ( 9936): SampleScaffoldWidgetState#build
I/flutter ( 9936): EmployeeEditor#constructor
I/flutter ( 9936): EmployeeEditor#createState
I/flutter ( 9936): EmployeeEditorState#constructor
I/flutter ( 9936): EmployeeEditorState#build
I/flutter ( 9936): onSubmit:Alice2
I/flutter ( 9936): SampleScaffoldWidgetState#build
I/flutter ( 9936): EmployeeEditor#constructor
I/flutter ( 9936): EmployeeEditorState#build
I/flutter ( 9936): select:Bob
I/flutter ( 9936): SampleScaffoldWidgetState#build
I/flutter ( 9936): EmployeeEditor#constructor
I/flutter ( 9936): EmployeeEditorState#build
いろいろググってみましたが、ピンとくる方法が見つかりませんでした。
ふとTextFieldのテキストの値を変更するのにTextEditingControllerとかを経由してたなと思いました。
少しおってみるとTextEditingControllerはValueNotifierを継承していました。
ValueNotifierのコードはこんなでした。
/// A [ChangeNotifier] that holds a single value.
///
/// When [value] is replaced, this class notifies its listeners.
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> {
/// Creates a [ChangeNotifier] that wraps this value.
ValueNotifier(this._value);
/// The current value stored in this notifier.
///
/// When the value is replaced, this class notifies its listeners.
@override
T get value => _value;
T _value;
set value(T newValue) {
if (_value == newValue)
return;
_value = newValue;
notifyListeners();
}
@override
String toString() => '${describeIdentity(this)}($value)';
}
値を一つ保持するChangeNotifierです。
値が置換されると、このクラスはリスナーにそれを通知する。
お、なんだかそれっぽいぞ。
つまりValueNotifierを経由して値の変更をEmployeeEditorStateに通知すればよいということか。
というわけで修正したのが次のコードです。
// 従業員Entity
class Employee {
int id;
String name;
Employee(this.id, this.name);
}
// Scaffold
class SampleScaffoldWidget extends StatefulWidget {
@override
State createState() => SampleScaffoldWidgetState();
}
// ScaffoldのState
class SampleScaffoldWidgetState extends State<SampleScaffoldWidget> {
List<Employee> employees = [
Employee(1, "Alice"),
Employee(2, "Bob"),
];
ValueNotifier<Employee> employeeChangeNotifier;
SampleScaffoldWidgetState() {
employeeChangeNotifier = ValueNotifier<Employee>(employees[0]);
}
@override
Widget build(BuildContext context) {
print("SampleScaffoldWidgetState#build");
return Scaffold(
appBar: AppBar(
title: Text("Employee Editor"),
),
body: Column(
children: <Widget>[
Flexible(
child: ListView.builder(
itemCount: employees.length,
itemBuilder: (context, i) {
Employee employee = employees[i];
return ListTile(
title: Text(employee.name),
onTap: () {
// setStateは不要
// notifierを経由してEmployeeEditStateに値の変更を通知
employeeChangeNotifier.value = employee;
},
);
}),
),
EmployeeEditor(
notifier: employeeChangeNotifier,
onSubmit: (Employee _employee) {
print("onSubmit:${_employee.name}");
employees.asMap().forEach((i, employee) {
if (_employee.id == employee.id) {
setState(() {
employees[i] = _employee;
});
}
});
},
)
],
));
}
}
// 従業員名を編集するためのWidget
// コンストラクタで編集対象のEmployeeを渡す
class EmployeeEditor extends StatefulWidget {
EditEmployeeCallback onSubmit;
ValueNotifier<Employee> notifier;
EmployeeEditor({this.onSubmit, this.notifier}) {
print("EmployeeEditor#constructor");
}
@override
State createState() {
print("EmployeeEditor#createState");
return EmployeeEditorState(notifier: notifier);
}
}
// 従業員名を編集完了されたときのCallback
typedef void EditEmployeeCallback(Employee employee);
// 従業員名を編集するためのWidgetのState
class EmployeeEditorState extends State<EmployeeEditor> {
Employee employee;
ValueNotifier<Employee> notifier;
EmployeeEditorState({this.notifier}) {
print("EmployeeEditorState#constructor");
notifier.addListener(() {
// notifierを経由して値が変更されたらビューを更新する
setState(() {
this.employee = notifier.value;
_controller.text = employee.name;
});
});
// notifierから初期値を取得
employee = this.notifier.value;
_controller = TextEditingController(text: this.notifier.value.name);
}
TextEditingController _controller;
@override
Widget build(BuildContext context) {
print("EmployeeEditorState#build");
return Row(
children: <Widget>[
new Flexible(
child: Container(
margin: EdgeInsets.only(left: 8.0, right: 8.0),
child: TextField(
autofocus: true,
controller: _controller,
decoration: new InputDecoration.collapsed(hintText: "Name"),
),
),
),
Container(
child: FlatButton(
onPressed: () {
employee.name = _controller.text;
widget.onSubmit(employee);
},
child: Text("Done")),
)
],
);
}
}
動作は以下のようになりました。
意図したとおり、ListTileをタップすると選択されたEmployeeの名前がエディタに設定されて、DONEをクリックするとnameが更新されてリストにも変更が反映されるようになりました。
他にも方法があるかもしれませんが、今回実現できたのはこのような方法だったので紹介しました。
お作法的にもそんなに悪くないような気もします。