【Flutter】SlackアプリっぽいTextFieldの作り方
個人開発アプリでSlackアプリ(iOS)っぽいTextFieldを実装しました。webで検索してもズバリこれという記事がなく、少し苦労したのでここに書いておきます。
要件
この記事で扱う「SlackっぽいTextField」は、以下の要件を満たすように作ることにしました。要件はSlackのアプリを実際に触ってみて挙動を確かめることで決めました。
- 「入力待機」と「入力中」の状態に分かれる
- Slackアプリでは、文字入力を行なっていない時(キーボードが表示されていない時)はTextFieldが小さく表示されています。入力中はキーボードの上に広がって表示されます
- 入力待機時にTextFieldをタップするとキーボードが起動します
- 入力待機時と入力中では、画像やファイルの追加ボタンのレイアウトが変わります
- 入力中はスワイプまたはタップでフィールドの縦幅を変更できる
- 入力中はTextFieldのUIをスワイプすることでUIを画面の縦幅いっぱいに広げたり、元の縦幅に戻したりすることができます
「入力待機」状態と「入力中」状態
SlackアプリのTextFieldは入力待機と入力中でUIのレイアウトが異なります。
入力待機状態では、以下の画像のように一行に全てのUIが収まっています。
このUIの「#random へのメッセージ」をタップすると、入力中状態に遷移します。入力中はキーボードが表示されます。
入力中状態になると、ファイルや画像の追加ボタンがTextFieldの下段に表示されるようにレイアウトが変更されます。また、TextFieldの上部にノブが表示されます。
TextFieldの縦幅変更
入力中は、TextFieldの縦幅をスワイプで変更することができます。
キーボードの上に載っている状態と、画面いっぱいに広がっている二つの状態があり、これらの状態をスワイプによって切り替えられます。
- キーボードの上に載っている状態
- 画面いっぱいに広がっている状態
切り替え操作は、ドラッグでそのまま持っていく方法と、勢いよくスワイプする方法があります。
作ったもの
コード
こちらが実装したコードです。
コード全文はこちら
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: Scaffold(
appBar: AppBar(
title: Text("This is App Bar"),
backgroundColor: Theme.of(context).colorScheme.primary,
),
body: Center(child: Text("Body")),
bottomSheet: InputArea(),
),
);
}
}
enum PositionState {
unfocused,
min,
max,
dragging,
}
class InputArea extends StatefulWidget {
InputArea({super.key, this.addImageEnabled = false});
final bool addImageEnabled;
InputAreaState createState() => InputAreaState();
}
class InputAreaState extends State<InputArea> with TickerProviderStateMixin {
final _focusNode = FocusNode();
final _textFieldKey = GlobalKey();
final scrollController = ScrollController();
double _minHeight = 0;
PositionState _positionState = PositionState.unfocused;
final _key = GlobalKey();
late AnimationController _animationController;
late Animation<double> _animation;
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300), vsync: this);
_animation = const AlwaysStoppedAnimation(0.0);
_focusNode.addListener(() {
setState(() {
if (_focusNode.hasFocus) {
_positionState = PositionState.min;
} else {
_positionState = PositionState.unfocused;
}
});
});
}
void _animateTo(double from, double to, PositionState nextState) {
_animationController.forward(from: 0).then((_) {
_positionState = nextState;
});
_animation = Tween<double>(begin: from, end: to).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
setState(() {});
}
Widget build(BuildContext context) {
return
//入力欄
_positionState != PositionState.unfocused
? LayoutBuilder(builder: (context, constraints) {
return GestureDetector(
onVerticalDragStart: (_) {
setState(() {
final textFieldRenderBox = _textFieldKey.currentContext
?.findRenderObject() as RenderBox;
if (_positionState == PositionState.min) {
_minHeight = textFieldRenderBox
.size.height; //テキストフィールドの高さを保存しておく
_animation =
AlwaysStoppedAnimation(_minHeight); //高さをリセット
}
});
},
onVerticalDragUpdate: (details) {
if (_positionState == PositionState.min &&
details.delta.dy > 0) {
_focusNode.unfocus();
return;
}
_positionState = PositionState.dragging;
setState(() {
final textFieldRenderBox = _textFieldKey.currentContext
?.findRenderObject() as RenderBox;
var sheetHeight = textFieldRenderBox.size.height;
sheetHeight -= details.delta.dy;
if (sheetHeight < _minHeight) {
sheetHeight = _minHeight;
}
if (sheetHeight > constraints.maxHeight) {
sheetHeight = constraints.maxHeight;
}
_animation = AlwaysStoppedAnimation(sheetHeight);
});
},
onVerticalDragEnd: (details) {
final medium = (constraints.maxHeight + _minHeight) / 2;
final velocity = details.velocity;
const velocityThreshold = 1000;
double target = _animation.value > medium
? constraints.maxHeight
: _minHeight;
var nextState = _animation.value > medium
? PositionState.max
: PositionState.min;
if (_animation.value > medium) {
if (velocity.pixelsPerSecond.dy > velocityThreshold) {
target = _minHeight;
nextState = PositionState.min;
}
} else {
if (velocity.pixelsPerSecond.dy < -velocityThreshold) {
target = constraints.maxHeight;
nextState = PositionState.max;
}
}
setState(() {
_animateTo(_animation.value, target, nextState);
});
},
child: Material(
color: Theme.of(context).colorScheme.secondary,
elevation: 40,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12)),
side: BorderSide(
width: 0.5,
strokeAlign: BorderSide.strokeAlignOutside,
color:
Theme.of(context).colorScheme.outlineVariant),
),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Grabber(
onTap: () {
setState(() {
final textFieldRenderBox = _textFieldKey
.currentContext
?.findRenderObject() as RenderBox;
if (_positionState == PositionState.min) {
_minHeight =
textFieldRenderBox.size.height;
_animateTo(
_minHeight,
textFieldRenderBox
.constraints.maxHeight,
PositionState.max);
} else if (_positionState ==
PositionState.max) {
_animateTo(textFieldRenderBox.size.height,
_minHeight, PositionState.min);
}
});
},
isOnDesktopAndWeb: kIsWeb),
Flexible(
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
key: _key,
margin: const EdgeInsets.only(
left: 24, right: 24, bottom: 10),
height: _positionState ==
PositionState.dragging ||
_animationController.isAnimating
? _animation.value
: null,
child: GestureDetector(
onVerticalDragUpdate: (details) {},
child: TextField(
key: _textFieldKey,
scrollController: scrollController,
focusNode: _focusNode,
decoration: InputDecoration(
fillColor: Theme.of(context)
.colorScheme
.onSecondary,
filled: true,
border: InputBorder.none,
isDense: true),
onChanged: (_) {
setState(() {});
},
style: Theme.of(context)
.textTheme
.bodyMedium,
keyboardType: TextInputType.multiline,
autofillHints: null,
minLines: _positionState ==
PositionState.max
? null
: 1,
expands: !_animation.isAnimating &&
_positionState ==
PositionState.max,
maxLines:
(_animation is AlwaysStoppedAnimation ||
!_animation
.isAnimating) &&
_positionState ==
PositionState.min
? 5
: null,
),
),
);
}),
),
SizedBox(
height: 38,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {},
child: Icon(
Icons.image,
color: Theme.of(context)
.colorScheme
.onSecondary,
)),
const Spacer(),
],
),
),
SizedBox(height: 12),
],
),
),
));
})
: SizedBox(
height: 120,
child: GestureDetector(
onTap: () {
_focusNode.requestFocus();
},
onVerticalDragUpdate: (details) {
if (details.delta.dy < 0) {
_focusNode.requestFocus();
}
},
child: Material(
elevation: 40,
color: Theme.of(context).colorScheme.secondary,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12)),
side: BorderSide(
width: 0.5,
strokeAlign: BorderSide.strokeAlignOutside,
color:
Theme.of(context).colorScheme.outlineVariant),
),
child: Container(
margin: const EdgeInsets.only(right: 24, bottom: 10),
child: Row(
children: [
TextButton(
onPressed: () {},
child: Icon(
Icons.image,
color:
Theme.of(context).colorScheme.onSecondary,
)),
Expanded(
child: TextField(
key: _textFieldKey,
scrollController: scrollController,
focusNode: _focusNode,
decoration: InputDecoration(
fillColor: Theme.of(context)
.colorScheme
.onSecondary,
border: InputBorder.none,
filled: true,
isDense: true),
style: Theme.of(context).textTheme.bodyMedium,
keyboardType: TextInputType.multiline,
autofillHints: null,
minLines: 1,
maxLines: 1,
),
),
],
),
)),
),
);
}
void dispose() {
_focusNode.dispose();
_animationController.dispose();
super.dispose();
}
}
/// A draggable widget that accepts vertical drag gestures
/// and this is only visible on desktop and web platforms.
class Grabber extends StatelessWidget {
const Grabber({
super.key,
required this.onTap,
required this.isOnDesktopAndWeb,
});
final void Function() onTap;
final bool isOnDesktopAndWeb;
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
color: Colors.transparent,
height: 24,
child: Align(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8.0),
width: 48.0,
height: 6.0,
decoration: BoxDecoration(
color: colorScheme.outline,
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
);
}
}
実行結果
こちらが上記のコードの実行結果です。
実装のポイント
入力中の縦幅変更
入力中の縦幅変更を実現するため、GestureDetectorで入力を監視しています。
//前略
return GestureDetector(
onVerticalDragStart: (_) {
setState(() {
final textFieldRenderBox = _textFieldKey.currentContext
?.findRenderObject() as RenderBox;
if (_positionState == PositionState.min) {
_minHeight = textFieldRenderBox
.size.height; //テキストフィールドの高さを保存しておく
_animation =
AlwaysStoppedAnimation(_minHeight); //高さをリセット
}
});
},
onVerticalDragUpdate: (details) {
if (_positionState == PositionState.min &&
details.delta.dy > 0) {
_focusNode.unfocus();
return;
}
_positionState = PositionState.dragging;
setState(() {
final textFieldRenderBox = _textFieldKey.currentContext
?.findRenderObject() as RenderBox;
var sheetHeight = textFieldRenderBox.size.height;
sheetHeight -= details.delta.dy;
if (sheetHeight < _minHeight) {
sheetHeight = _minHeight;
}
if (sheetHeight > constraints.maxHeight) {
sheetHeight = constraints.maxHeight;
}
_animation = AlwaysStoppedAnimation(sheetHeight);
});
},
onVerticalDragEnd: (details) {
final medium = (constraints.maxHeight + _minHeight) / 2;
final velocity = details.velocity;
const velocityThreshold = 1000;
double target = _animation.value > medium
? constraints.maxHeight
: _minHeight;
var nextState = _animation.value > medium
? PositionState.max
: PositionState.min;
if (_animation.value > medium) {
if (velocity.pixelsPerSecond.dy > velocityThreshold) {
target = _minHeight;
nextState = PositionState.min;
}
} else {
if (velocity.pixelsPerSecond.dy < -velocityThreshold) {
target = constraints.maxHeight;
nextState = PositionState.max;
}
}
setState(() {
_animateTo(_animation.value, target, nextState);
});
},
child: Material(
//以下略
ドラッグ操作の結果、TextFieldが縮んだ状態と広がった状態のどちらかになるように実装しています。結果の状態を決定するロジックは以下の部分に記述しています。
onVerticalDragEnd: (details) {
final medium = (constraints.maxHeight + _minHeight) / 2;
final velocity = details.velocity;
const velocityThreshold = 1000;
double target = _animation.value > medium
? constraints.maxHeight
: _minHeight;
var nextState = _animation.value > medium
? PositionState.max
: PositionState.min;
if (_animation.value > medium) {
if (velocity.pixelsPerSecond.dy > velocityThreshold) {
target = _minHeight;
nextState = PositionState.min;
}
} else {
if (velocity.pixelsPerSecond.dy < -velocityThreshold) {
target = constraints.maxHeight;
nextState = PositionState.max;
}
}
setState(() {
_animateTo(_animation.value, target, nextState);
});
まずmaxHeightとminHeightの中間点を計算し、その位置より上ならmaxに、下ならminを目標値にします。次に、ドラッグ終了時のスワイプ速度を見て、velocityThreshold以上であれば、スワイプしていた方向を目標値とします。
アニメーション
Slackの入力フィールドは、スワイプ操作の間は指の位置に追従し、終わった後はminかmaxの幅までアニメーションします。この挙動を再現するため、Tween関数を使用しています。
void _animateTo(double from, double to, PositionState nextState) {
_animationController.forward(from: 0).then((_) {
_positionState = nextState;
});
_animation = Tween<double>(begin: from, end: to).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut));
setState(() {});
}
スワイプ操作中はAlwaysStoppedAnimationを_animationに代入しています。
TextFieldのパラメータ
TextFieldには表示される行数を決定するためのパラメータがあります。minLinesとmaxLinesがそれに当たります。また、TextFieldを利用できる縦幅いっぱいに広げたい際は、expandsをtrueにします。
minLines/maxLinesとexpandsには密接な関係があり、expandsをtrueにする際はmaxLinesとminLinesをnullにしなければなりません。
child: TextField(
key: _textFieldKey,
scrollController: scrollController,
focusNode: _focusNode,
decoration: InputDecoration(
fillColor: Theme.of(context)
.colorScheme
.onSecondary,
filled: true,
border: InputBorder.none,
isDense: true),
onChanged: (_) {
setState(() {});
},
style: Theme.of(context)
.textTheme
.bodyMedium,
keyboardType: TextInputType.multiline,
autofillHints: null,
minLines: _positionState ==
PositionState.max
? null
: 1,
expands: !_animation.isAnimating &&
_positionState ==
PositionState.max,
maxLines:
(_animation is AlwaysStoppedAnimation ||
!_animation
.isAnimating) &&
_positionState ==
PositionState.min
? 5
: null,
),
うまくいかなかった方法
DraggableScrollableSheetを使う
DraggableScrollableSheetというウィジェットがあり、これがやりたいことに近そうだったので、当初はこれを使って実装を進めていました。https://api.flutter.dev/flutter/widgets/DraggableScrollableSheet-class.html
しかし、以下の理由でうまくいかず、この方針は断念しました。
- max, minChildSizeが利用可能なスペースに対する割合(0.0から1.0)の指定だった。今回はピクセル値でサイズを指定したかったので都合が悪かった
- minHeightかmaxHeightのどちらかにフィットするように実装したかったが、DraggableScrollableSheetのデフォルトの挙動はドラッグが完了した位置でシートの高さを固定するようになっており、ここを独自コードで改造するくらいなら最初からDraggableScrollableSheetを使わない方が楽そうだった
おまけ
上記ではこの記事用に作ったサンプルコードの挙動を掲載しました。おまけとして、自分が今作っているアプリの動作のgifも掲載しておきます。
まとめ
この記事ではSlackっぽい入力フィールドを実現する方法を紹介しました。状態管理周りなど、まだ改善点はありそうですが、とりあえず動くものができたので共有させていただきました。どなたかの参考になれば幸いです。
Discussion