Skip to content

Commit 0bcd67f

Browse files
committed
ui: Support edited/moved marker swipe animation.
This adds full support to the edited/moved marker feature by allowing the user to expand the edited/moved marker to show a helper text on a colored block in the background. The marker retracts as soon as the user releases the touch. Fixes #171. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 7b49c2a commit 0bcd67f

File tree

1 file changed

+94
-17
lines changed

1 file changed

+94
-17
lines changed

lib/widgets/edit_state_marker.dart

+94-17
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:ui';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter/rendering.dart';
35
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
@@ -20,7 +22,41 @@ class SwipableMessageRow extends StatefulWidget {
2022
State<StatefulWidget> createState() => _SwipableMessageRowState();
2123
}
2224

23-
class _SwipableMessageRowState extends State<SwipableMessageRow> {
25+
class _SwipableMessageRowState extends State<SwipableMessageRow> with TickerProviderStateMixin {
26+
@override
27+
void initState() {
28+
super.initState();
29+
_controller = AnimationController(
30+
// The duration is only used when `_controller.reverse()` is called,
31+
// i.e.: when the drag is released and the marker gets collapsed.
32+
duration: const Duration(milliseconds: 200),
33+
lowerBound: EditStateMarker.widthCollapsed,
34+
upperBound: EditStateMarker.widthExpanded,
35+
vsync: this)
36+
..addListener(() => setState((){}));
37+
// This controls the movement of the message content.
38+
_slideAnimation = Tween<Offset>(begin: const Offset(0, 0), end: const Offset(1, 0))
39+
.animate(_controller);
40+
}
41+
42+
@override
43+
void dispose() {
44+
_controller.dispose();
45+
super.dispose();
46+
}
47+
48+
late AnimationController _controller;
49+
late Animation<Offset> _slideAnimation;
50+
double get dragOffset => _controller.value - EditStateMarker.widthCollapsed;
51+
52+
void _handleDragUpdate(DragUpdateDetails details) {
53+
_controller.value += details.delta.dx;
54+
}
55+
56+
void _handleDragEnd(DragEndDetails details) {
57+
_controller.reverse();
58+
}
59+
2460
@override
2561
Widget build(BuildContext context) {
2662
final theme = Theme.of(context).extension<DesignVariables>()!;
@@ -32,36 +68,67 @@ class _SwipableMessageRowState extends State<SwipableMessageRow> {
3268
Icon(ZulipIcons.star_filled, size: 16, color: theme.starColor)]));
3369
final hasMarker = widget.message.editState != MessageEditState.none;
3470

35-
return Stack(
71+
final content = Stack(
3672
children: [
3773
if (hasMarker) Positioned(
3874
left: 0,
39-
child: EditStateMarker(editState: widget.message.editState)),
75+
child: EditStateMarker(
76+
editState: widget.message.editState,
77+
animation: _controller)),
4078
Padding(
41-
padding: const EdgeInsets.only(left: 16, right: 16),
42-
child: widget.child),
79+
// Adding [EditStateMarker.widthCollapsed] to the right padding
80+
// cancels out the left offset applied from the initial value of
81+
// [_slideAnimation] through [Transform.translate]. This is necessary
82+
// before we can add a padding of 16 pixels for the star.
83+
padding: const EdgeInsets.only(right: EditStateMarker.widthCollapsed + 16),
84+
child: Transform.translate(
85+
offset: _slideAnimation.value,
86+
child: widget.child)),
4387
if (widget.message.flags.contains(MessageFlag.starred))
4488
Positioned(
45-
right: 0,
89+
// Because _controller.value does not start from zero, we subtract
90+
// its initial value from it so the star is positioned correctly
91+
// in the beginning.
92+
right: 0 - (_controller.value - EditStateMarker.widthCollapsed),
4693
child: star),
4794
],
4895
);
96+
97+
if (!hasMarker) return content;
98+
99+
return GestureDetector(
100+
onHorizontalDragEnd: _handleDragEnd,
101+
onHorizontalDragUpdate: _handleDragUpdate,
102+
child: content,
103+
);
49104
}
50105
}
51106

52107
class EditStateMarker extends StatelessWidget {
53108
/// The minimum width of the marker.
54109
///
55-
/// Currently, only the collapsed state of the marker has been implemented,
56-
/// where only the marker icon, not the marker text, is visible.
110+
/// This is when no drag has been performed on the message row
111+
/// where only the moved/edited icon, not the text, is visible.
57112
static const double widthCollapsed = 16;
58113

114+
/// The maximum width of the marker.
115+
///
116+
/// This is typically wider than the colored pill when the marker is fully
117+
/// expanded. At that point only the blank space to the right of the colored
118+
/// block will grow until the marker reaches this width.
119+
static const double widthExpanded = 100;
120+
59121
const EditStateMarker({
60122
super.key,
61123
required MessageEditState editState,
62-
}) : _editState = editState;
124+
required Animation<double> animation,
125+
}) : _editState = editState, _animation = animation;
63126

64127
final MessageEditState _editState;
128+
final Animation<double> _animation;
129+
130+
double get _animationProgress => _animation.value / widthExpanded;
131+
double get _width => _animation.value;
65132

66133
@override
67134
Widget build(BuildContext context) {
@@ -93,34 +160,44 @@ class EditStateMarker extends StatelessWidget {
93160
children: [
94161
Flexible(
95162
fit: FlexFit.loose,
96-
// For now, [markerText] is not displayed because it is transparent and
97-
// there is not enough space in the parent ConstrainedBox.
163+
// The trailing space serves as a padding between the marker text and
164+
// the icon without needing another element in the row.
98165
child: Text('$markerText ',
99166
overflow: TextOverflow.clip,
100167
softWrap: false,
101168
textAlign: TextAlign.center,
102-
style: TextStyle(fontSize: 15, color: theme.textMarker.withAlpha(0)))),
169+
style: TextStyle(fontSize: 15, color: Color.lerp(
170+
theme.textMarker.withAlpha(0),
171+
theme.textMarker,
172+
_animationProgress)))),
103173
// To match the Figma design, we cannot make the collapsed width of the
104174
// marker larger. We need to explicitly allow the icon to overflow.
105175
OverflowBox(
106176
fit: OverflowBoxFit.deferToChild,
107177
maxWidth: 10,
108-
child: Icon(icon, size: iconSize, color: theme.textMarkerLight),
178+
child: Icon(icon, size: iconSize, color: Color.lerp(
179+
theme.textMarkerLight,
180+
theme.textMarker,
181+
_animationProgress)),
109182
),
110183
],
111184
);
112185

113186
return ConstrainedBox(
114-
constraints: const BoxConstraints(maxWidth: widthCollapsed),
187+
constraints: BoxConstraints(maxWidth: _width),
115188
child: Container(
116-
margin: const EdgeInsets.only(top: 4, left: 0),
189+
margin: EdgeInsets.only(top: 4, left: lerpDouble(0, 10, _animationProgress)!),
117190
clipBehavior: Clip.hardEdge,
118191
decoration: BoxDecoration(
119192
borderRadius: BorderRadius.circular(2),
120-
color: theme.bgMarker.withAlpha(0)),
193+
color: Color.lerp(
194+
theme.bgMarker.withAlpha(0),
195+
theme.bgMarker,
196+
_animationProgress)),
121197
child: Padding(
122198
padding: const EdgeInsets.all(1.0),
123-
child: marker),
199+
child: marker,
200+
),
124201
),
125202
);
126203
}

0 commit comments

Comments
 (0)