建立輸入指示器
現代聊天應用程式會在其他使用者正在積極輸入回覆時顯示指示器。這些指示器有助於防止您和對方之間快速且衝突的回應。在此食譜中,您將建立一個會動畫呈現進出視圖的對話泡泡輸入指示器。
以下動畫顯示應用程式的行為
定義輸入指示器小工具
#輸入指示器存在於其自己的小工具中,因此可以在您的應用程式中的任何位置使用。與任何控制動畫的小工具一樣,輸入指示器需要是一個有狀態的小工具。該小工具接受一個布林值,該值確定指示器是否可見。這個對話泡泡輸入指示器接受泡泡的顏色,以及大型對話泡泡內閃爍圓點的亮暗階段的兩種顏色。
定義一個名為 TypingIndicator
的新有狀態小工具。
class TypingIndicator extends StatefulWidget {
const TypingIndicator({
super.key,
this.showIndicator = false,
this.bubbleColor = const Color(0xFF646b7f),
this.flashingCircleDarkColor = const Color(0xFF333333),
this.flashingCircleBrightColor = const Color(0xFFaec1dd),
});
final bool showIndicator;
final Color bubbleColor;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator> {
@override
Widget build(BuildContext context) {
// TODO:
return const SizedBox();
}
}
為輸入指示器騰出空間
#輸入指示器在未顯示時不佔用任何空間。因此,指示器在出現時需要增加高度,而在消失時需要縮小高度。
輸入指示器的高度可以是輸入指示器內對話泡泡的自然高度。但是,對話泡泡會隨著彈性曲線擴展。如果快速將所有對話訊息向上或向下推,這種彈性在視覺上會太過突兀。相反地,輸入指示器的高度會自行動畫呈現,在泡泡出現之前平滑擴展。當泡泡消失時,高度會平滑地收縮至零。此行為需要對輸入指示器的高度進行明確的動畫。
為輸入指示器的高度定義動畫,然後將該動畫值應用到輸入指示器內的 SizedBox
小工具。
class _TypingIndicatorState extends State<TypingIndicator>
with TickerProviderStateMixin {
late AnimationController _appearanceController;
late Animation<double> _indicatorSpaceAnimation;
@override
void initState() {
super.initState();
_appearanceController = AnimationController(
vsync: this,
);
_indicatorSpaceAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
reverseCurve: const Interval(0.0, 1.0, curve: Curves.easeOut),
).drive(Tween<double>(
begin: 0.0,
end: 60.0,
));
if (widget.showIndicator) {
_showIndicator();
}
}
@override
void didUpdateWidget(TypingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showIndicator != oldWidget.showIndicator) {
if (widget.showIndicator) {
_showIndicator();
} else {
_hideIndicator();
}
}
}
@override
void dispose() {
_appearanceController.dispose();
super.dispose();
}
void _showIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 750)
..forward();
}
void _hideIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 150)
..reverse();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _indicatorSpaceAnimation,
builder: (context, child) {
return SizedBox(
height: _indicatorSpaceAnimation.value,
);
},
);
}
}
TypingIndicator
根據傳入的 showIndicator
變數是 true
還是 false
,分別向前或向後執行動畫。
控制高度的動畫會根據其方向使用不同的動畫曲線。當動畫向前移動時,它需要快速為對話泡泡騰出空間。因此,向前曲線會在整體外觀動畫的前 40% 內執行整個高度動畫。當動畫反向時,它需要讓對話泡泡有足夠的時間消失,然後才縮小高度。使用所有可用時間的 ease-out 曲線是完成此行為的好方法。
動畫呈現對話泡泡
#輸入指示器會顯示三個對話泡泡。前兩個泡泡是小而圓的。第三個泡泡是長橢圓形的,包含一些閃爍的圓點。這些泡泡從可用空間的左下角交錯排列。
每個泡泡都會透過將其比例從 0% 動畫呈現到 100% 來出現,並且每個泡泡會在稍微不同的時間執行此操作,使其看起來像是每個泡泡在它之前的泡泡之後出現。這稱為交錯動畫。
從左下方以所需的位置繪製三個泡泡。然後,對泡泡的比例進行動畫處理,以便每當 showIndicator
屬性變更時,泡泡都會交錯排列。
class _TypingIndicatorState extends State<TypingIndicator>
with TickerProviderStateMixin {
late AnimationController _appearanceController;
late Animation<double> _indicatorSpaceAnimation;
late Animation<double> _smallBubbleAnimation;
late Animation<double> _mediumBubbleAnimation;
late Animation<double> _largeBubbleAnimation;
late AnimationController _repeatingController;
final List<Interval> _dotIntervals = const [
Interval(0.25, 0.8),
Interval(0.35, 0.9),
Interval(0.45, 1.0),
];
@override
void initState() {
super.initState();
_appearanceController = AnimationController(
vsync: this,
)..addListener(() {
setState(() {});
});
_indicatorSpaceAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
reverseCurve: const Interval(0.0, 1.0, curve: Curves.easeOut),
).drive(Tween<double>(
begin: 0.0,
end: 60.0,
));
_smallBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.5, curve: Curves.elasticOut),
reverseCurve: const Interval(0.0, 0.3, curve: Curves.easeOut),
);
_mediumBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.2, 0.7, curve: Curves.elasticOut),
reverseCurve: const Interval(0.2, 0.6, curve: Curves.easeOut),
);
_largeBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.3, 1.0, curve: Curves.elasticOut),
reverseCurve: const Interval(0.5, 1.0, curve: Curves.easeOut),
);
if (widget.showIndicator) {
_showIndicator();
}
}
@override
void didUpdateWidget(TypingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showIndicator != oldWidget.showIndicator) {
if (widget.showIndicator) {
_showIndicator();
} else {
_hideIndicator();
}
}
}
@override
void dispose() {
_appearanceController.dispose();
super.dispose();
}
void _showIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 750)
..forward();
}
void _hideIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 150)
..reverse();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _indicatorSpaceAnimation,
builder: (context, child) {
return SizedBox(
height: _indicatorSpaceAnimation.value,
child: child,
);
},
child: Stack(
children: [
AnimatedBubble(
animation: _smallBubbleAnimation,
left: 8,
bottom: 8,
bubble: CircleBubble(
size: 8,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _mediumBubbleAnimation,
left: 10,
bottom: 10,
bubble: CircleBubble(
size: 16,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _largeBubbleAnimation,
left: 12,
bottom: 12,
bubble: StatusBubble(
dotIntervals: _dotIntervals,
flashingCircleDarkColor: widget.flashingCircleDarkColor,
flashingCircleBrightColor: widget.flashingCircleBrightColor,
bubbleColor: widget.bubbleColor,
),
),
],
),
);
}
}
class CircleBubble extends StatelessWidget {
const CircleBubble({
super.key,
required this.size,
required this.bubbleColor,
});
final double size;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bubbleColor,
),
);
}
}
class AnimatedBubble extends StatelessWidget {
const AnimatedBubble({
super.key,
required this.animation,
required this.left,
required this.bottom,
required this.bubble,
});
final Animation<double> animation;
final double left;
final double bottom;
final Widget bubble;
@override
Widget build(BuildContext context) {
return Positioned(
left: left,
bottom: bottom,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.scale(
scale: animation.value,
alignment: Alignment.bottomLeft,
child: child,
);
},
child: bubble,
),
);
}
}
class StatusBubble extends StatelessWidget {
const StatusBubble({
super.key,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
required this.bubbleColor,
});
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: 85,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(27),
color: bubbleColor,
),
);
}
}
動畫呈現閃爍圓點
#在大型對話泡泡中,輸入指示器會顯示三個會重複閃爍的小圓點。每個圓點會在稍微不同的時間閃爍,給人一種單一光源在每個圓點後面移動的印象。這種閃爍動畫會無限重複。
引入一個重複的 AnimationController
來實作圓點閃爍,並將其傳遞給 StatusBubble
。
class _TypingIndicatorState extends State<TypingIndicator>
with TickerProviderStateMixin {
late AnimationController _appearanceController;
late Animation<double> _indicatorSpaceAnimation;
late Animation<double> _smallBubbleAnimation;
late Animation<double> _mediumBubbleAnimation;
late Animation<double> _largeBubbleAnimation;
late AnimationController _repeatingController;
final List<Interval> _dotIntervals = const [
Interval(0.25, 0.8),
Interval(0.35, 0.9),
Interval(0.45, 1.0),
];
@override
void initState() {
super.initState();
// other initializations...
_repeatingController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
if (widget.showIndicator) {
_showIndicator();
}
}
@override
void dispose() {
_appearanceController.dispose();
_repeatingController.dispose();
super.dispose();
}
void _showIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 750)
..forward();
_repeatingController.repeat(); // <-- Add this
}
void _hideIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 150)
..reverse();
_repeatingController.stop(); // <-- Add this
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _indicatorSpaceAnimation,
builder: (context, child) {
return SizedBox(
height: _indicatorSpaceAnimation.value,
child: child,
);
},
child: Stack(
children: [
AnimatedBubble(
animation: _smallBubbleAnimation,
left: 8,
bottom: 8,
bubble: CircleBubble(
size: 8,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _mediumBubbleAnimation,
left: 10,
bottom: 10,
bubble: CircleBubble(
size: 16,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _largeBubbleAnimation,
left: 12,
bottom: 12,
bubble: StatusBubble(
repeatingController: _repeatingController, // <-- Add this
dotIntervals: _dotIntervals,
flashingCircleDarkColor: widget.flashingCircleDarkColor,
flashingCircleBrightColor: widget.flashingCircleBrightColor,
bubbleColor: widget.bubbleColor,
),
),
],
),
);
}
}
class StatusBubble extends StatelessWidget {
const StatusBubble({
super.key,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
required this.bubbleColor,
});
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: 85,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(27),
color: bubbleColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FlashingCircle(
index: 0,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
FlashingCircle(
index: 1,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
FlashingCircle(
index: 2,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
],
),
);
}
}
class FlashingCircle extends StatelessWidget {
const FlashingCircle({
super.key,
required this.index,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
});
final int index;
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: repeatingController,
builder: (context, child) {
final circleFlashPercent = dotIntervals[index].transform(
repeatingController.value,
);
final circleColorPercent = sin(pi * circleFlashPercent);
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
flashingCircleDarkColor,
flashingCircleBrightColor,
circleColorPercent,
),
),
);
},
);
}
}
每個圓點都會使用正弦 (sin
) 函數來計算其顏色,以便顏色在最小值和最大值點逐漸變更。此外,每個圓點會在指定間隔內動畫呈現其顏色,該間隔佔用整體動畫時間的一部分。這些間隔的位置會產生單一光源在三個點後面移動的視覺效果。
恭喜!您現在有一個輸入指示器,可讓使用者知道其他人何時正在輸入。指示器會動畫呈現進出,並在其他使用者輸入時顯示重複動畫。
互動範例
#執行應用程式
- 按一下螢幕底部的圓形開啟/關閉開關,以開啟和關閉輸入指示器泡泡。
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
const MaterialApp(
home: ExampleIsTyping(),
debugShowCheckedModeBanner: false,
),
);
}
const _backgroundColor = Color(0xFF333333);
class ExampleIsTyping extends StatefulWidget {
const ExampleIsTyping({
super.key,
});
@override
State<ExampleIsTyping> createState() => _ExampleIsTypingState();
}
class _ExampleIsTypingState extends State<ExampleIsTyping> {
bool _isSomeoneTyping = false;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _backgroundColor,
appBar: AppBar(
title: const Text('Typing Indicator'),
),
body: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: 25,
reverse: true,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(left: 100),
child: FakeMessage(isBig: index.isOdd),
);
},
),
),
Align(
alignment: Alignment.bottomLeft,
child: TypingIndicator(
showIndicator: _isSomeoneTyping,
),
),
Container(
color: Colors.grey,
padding: const EdgeInsets.all(16),
child: Center(
child: CupertinoSwitch(
onChanged: (newValue) {
setState(() {
_isSomeoneTyping = newValue;
});
},
value: _isSomeoneTyping,
),
),
),
],
),
);
}
}
class TypingIndicator extends StatefulWidget {
const TypingIndicator({
super.key,
this.showIndicator = false,
this.bubbleColor = const Color(0xFF646b7f),
this.flashingCircleDarkColor = const Color(0xFF333333),
this.flashingCircleBrightColor = const Color(0xFFaec1dd),
});
final bool showIndicator;
final Color bubbleColor;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with TickerProviderStateMixin {
late AnimationController _appearanceController;
late Animation<double> _indicatorSpaceAnimation;
late Animation<double> _smallBubbleAnimation;
late Animation<double> _mediumBubbleAnimation;
late Animation<double> _largeBubbleAnimation;
late AnimationController _repeatingController;
final List<Interval> _dotIntervals = const [
Interval(0.25, 0.8),
Interval(0.35, 0.9),
Interval(0.45, 1.0),
];
@override
void initState() {
super.initState();
_appearanceController = AnimationController(
vsync: this,
)..addListener(() {
setState(() {});
});
_indicatorSpaceAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
reverseCurve: const Interval(0.0, 1.0, curve: Curves.easeOut),
).drive(Tween<double>(
begin: 0.0,
end: 60.0,
));
_smallBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.0, 0.5, curve: Curves.elasticOut),
reverseCurve: const Interval(0.0, 0.3, curve: Curves.easeOut),
);
_mediumBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.2, 0.7, curve: Curves.elasticOut),
reverseCurve: const Interval(0.2, 0.6, curve: Curves.easeOut),
);
_largeBubbleAnimation = CurvedAnimation(
parent: _appearanceController,
curve: const Interval(0.3, 1.0, curve: Curves.elasticOut),
reverseCurve: const Interval(0.5, 1.0, curve: Curves.easeOut),
);
_repeatingController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
if (widget.showIndicator) {
_showIndicator();
}
}
@override
void didUpdateWidget(TypingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showIndicator != oldWidget.showIndicator) {
if (widget.showIndicator) {
_showIndicator();
} else {
_hideIndicator();
}
}
}
@override
void dispose() {
_appearanceController.dispose();
_repeatingController.dispose();
super.dispose();
}
void _showIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 750)
..forward();
_repeatingController.repeat();
}
void _hideIndicator() {
_appearanceController
..duration = const Duration(milliseconds: 150)
..reverse();
_repeatingController.stop();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _indicatorSpaceAnimation,
builder: (context, child) {
return SizedBox(
height: _indicatorSpaceAnimation.value,
child: child,
);
},
child: Stack(
children: [
AnimatedBubble(
animation: _smallBubbleAnimation,
left: 8,
bottom: 8,
bubble: CircleBubble(
size: 8,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _mediumBubbleAnimation,
left: 10,
bottom: 10,
bubble: CircleBubble(
size: 16,
bubbleColor: widget.bubbleColor,
),
),
AnimatedBubble(
animation: _largeBubbleAnimation,
left: 12,
bottom: 12,
bubble: StatusBubble(
repeatingController: _repeatingController,
dotIntervals: _dotIntervals,
flashingCircleDarkColor: widget.flashingCircleDarkColor,
flashingCircleBrightColor: widget.flashingCircleBrightColor,
bubbleColor: widget.bubbleColor,
),
),
],
),
);
}
}
class CircleBubble extends StatelessWidget {
const CircleBubble({
super.key,
required this.size,
required this.bubbleColor,
});
final double size;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bubbleColor,
),
);
}
}
class AnimatedBubble extends StatelessWidget {
const AnimatedBubble({
super.key,
required this.animation,
required this.left,
required this.bottom,
required this.bubble,
});
final Animation<double> animation;
final double left;
final double bottom;
final Widget bubble;
@override
Widget build(BuildContext context) {
return Positioned(
left: left,
bottom: bottom,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.scale(
scale: animation.value,
alignment: Alignment.bottomLeft,
child: child,
);
},
child: bubble,
),
);
}
}
class StatusBubble extends StatelessWidget {
const StatusBubble({
super.key,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
required this.bubbleColor,
});
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
final Color bubbleColor;
@override
Widget build(BuildContext context) {
return Container(
width: 85,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(27),
color: bubbleColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FlashingCircle(
index: 0,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
FlashingCircle(
index: 1,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
FlashingCircle(
index: 2,
repeatingController: repeatingController,
dotIntervals: dotIntervals,
flashingCircleDarkColor: flashingCircleDarkColor,
flashingCircleBrightColor: flashingCircleBrightColor,
),
],
),
);
}
}
class FlashingCircle extends StatelessWidget {
const FlashingCircle({
super.key,
required this.index,
required this.repeatingController,
required this.dotIntervals,
required this.flashingCircleBrightColor,
required this.flashingCircleDarkColor,
});
final int index;
final AnimationController repeatingController;
final List<Interval> dotIntervals;
final Color flashingCircleDarkColor;
final Color flashingCircleBrightColor;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: repeatingController,
builder: (context, child) {
final circleFlashPercent = dotIntervals[index].transform(
repeatingController.value,
);
final circleColorPercent = sin(pi * circleFlashPercent);
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.lerp(
flashingCircleDarkColor,
flashingCircleBrightColor,
circleColorPercent,
),
),
);
},
);
}
}
class FakeMessage extends StatelessWidget {
const FakeMessage({
super.key,
required this.isBig,
});
final bool isBig;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 24),
height: isBig ? 128 : 36,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: Colors.grey.shade300,
),
);
}
}
除非另有說明,否則本網站上的文件反映了 Flutter 的最新穩定版本。頁面上次更新於 2024-06-26。 檢視來源 或 回報問題。