Skip to content

Commit 002f9d7

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 zulip#171. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 0156275 commit 002f9d7

File tree

2 files changed

+136
-19
lines changed

2 files changed

+136
-19
lines changed

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,5 +479,13 @@
479479
"senderFullName": {"type": "String", "example": "Alice"},
480480
"numOthers": {"type": "int", "example": "4"}
481481
}
482+
},
483+
"messageIsEdited": "Edited",
484+
"@messageIsEdited": {
485+
"description": "Text that appears on a marker next to an edited message."
486+
},
487+
"messageIsMoved": "Moved",
488+
"@messageIsMoved": {
489+
"description": "Text that appears on a marker next to a moved message."
482490
}
483491
}

lib/widgets/swipable_message_row.dart

Lines changed: 128 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import 'dart:ui';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter/rendering.dart';
5+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
36

47
import '../api/model/model.dart';
58
import 'icons.dart';
@@ -20,61 +23,167 @@ class SwipableMessageRow extends StatefulWidget {
2023
State<StatefulWidget> createState() => _SwipableMessageRowState();
2124
}
2225

23-
class _SwipableMessageRowState extends State<SwipableMessageRow> {
26+
class _SwipableMessageRowState extends State<SwipableMessageRow> with TickerProviderStateMixin {
27+
@override
28+
void initState() {
29+
super.initState();
30+
_controller = AnimationController(
31+
// The duration is only used when `_controller.reverse()` is called,
32+
// i.e.: when the drag is released and the marker gets collapsed.
33+
duration: const Duration(milliseconds: 200),
34+
lowerBound: _EditStateMarker.widthCollapsed,
35+
upperBound: _EditStateMarker.widthExpanded,
36+
vsync: this)
37+
..addListener(() => setState((){}));
38+
}
39+
40+
@override
41+
void dispose() {
42+
_controller.dispose();
43+
super.dispose();
44+
}
45+
46+
late AnimationController _controller;
47+
48+
void _handleDragUpdate(DragUpdateDetails details) {
49+
_controller.value += details.delta.dx;
50+
}
51+
52+
void _handleDragEnd(DragEndDetails details) {
53+
_controller.reverse();
54+
}
55+
2456
@override
2557
Widget build(BuildContext context) {
2658
final hasMarker = widget.message.editState != MessageEditState.none;
2759

28-
return Row(
29-
crossAxisAlignment: CrossAxisAlignment.baseline,
30-
textBaseline: localizedTextBaseline(context),
31-
children: [
32-
hasMarker
33-
? _EditStateMarker(editState: widget.message.editState)
34-
: const SizedBox(width: 16),
35-
...widget.children,
36-
],
60+
final content = LayoutBuilder(
61+
builder: (context, constraints) => OverflowBox(
62+
fit: OverflowBoxFit.deferToChild,
63+
alignment: Alignment.topLeft,
64+
maxWidth: double.infinity,
65+
child: Row(
66+
crossAxisAlignment: CrossAxisAlignment.baseline,
67+
textBaseline: localizedTextBaseline(context),
68+
children: [
69+
hasMarker
70+
? _EditStateMarker(
71+
editState: widget.message.editState,
72+
animation: _controller)
73+
: const SizedBox(width: 16),
74+
SizedBox(
75+
width: constraints.maxWidth - 16,
76+
child: Row(
77+
crossAxisAlignment: CrossAxisAlignment.baseline,
78+
textBaseline: localizedTextBaseline(context),
79+
children: widget.children),
80+
),
81+
])),
3782
);
38-
}
83+
84+
if (!hasMarker) return content;
85+
86+
return GestureDetector(
87+
onHorizontalDragEnd: _handleDragEnd,
88+
onHorizontalDragUpdate: _handleDragUpdate,
89+
child: content,
90+
);
91+
}
3992
}
4093

4194
class _EditStateMarker extends StatelessWidget {
4295
/// The minimum width of the marker.
43-
// Currently, only the collapsed state of the marker has been implemented,
44-
// where only the marker icon, not the marker text, is visible.
96+
///
97+
/// This is when no drag has been performed on the message row
98+
/// where only the moved/edited icon, not the text, is visible.
4599
static const double widthCollapsed = 16;
46100

101+
/// The maximum width of the marker.
102+
///
103+
/// This is typically wider than the colored pill when the marker is fully
104+
/// expanded. At that point only the blank space to the right of the colored
105+
/// block will grow until the marker reaches this width.
106+
static const double widthExpanded = 100;
107+
47108
const _EditStateMarker({
48109
required MessageEditState editState,
49-
}) : _editState = editState;
110+
required Animation<double> animation,
111+
}) : _editState = editState, _animation = animation;
50112

51113
final MessageEditState _editState;
114+
final Animation<double> _animation;
115+
116+
double get _animationProgress => (_animation.value - widthCollapsed) / widthExpanded;
52117

53118
@override
54119
Widget build(BuildContext context) {
55120
final designVariables = DesignVariables.of(context);
121+
final zulipLocalizations = ZulipLocalizations.of(context);
56122

57123
final IconData icon;
58124
final double iconSize;
125+
final String markerText;
59126

60127
switch (_editState) {
61128
case MessageEditState.none:
62129
return const SizedBox(width: widthCollapsed);
63130
case MessageEditState.edited:
64131
icon = ZulipIcons.edited;
65132
iconSize = 14;
133+
markerText = zulipLocalizations.messageIsEdited;
66134
break;
67135
case MessageEditState.moved:
68136
icon = ZulipIcons.message_moved;
69137
iconSize = 8;
138+
markerText = zulipLocalizations.messageIsMoved;
70139
break;
71140
}
72141

142+
var marker = Row(
143+
mainAxisAlignment: MainAxisAlignment.end,
144+
mainAxisSize: MainAxisSize.min,
145+
children: [
146+
Flexible(
147+
fit: FlexFit.loose,
148+
child: Text(markerText,
149+
overflow: TextOverflow.clip,
150+
softWrap: false,
151+
textAlign: TextAlign.center,
152+
style: TextStyle(fontSize: 15, color: Color.lerp(
153+
designVariables.editedMovedMarkerExpanded.withAlpha(0),
154+
designVariables.editedMovedMarkerExpanded,
155+
_animationProgress)))),
156+
SizedBox(width: lerpDouble(0, 5, _animationProgress)),
157+
// To match the Figma design, we cannot make the collapsed width of the
158+
// marker larger. We need to explicitly allow the icon to overflow.
159+
OverflowBox(
160+
fit: OverflowBoxFit.deferToChild,
161+
maxWidth: 8,
162+
child: Icon(icon, size: iconSize, color: Color.lerp(
163+
designVariables.editedMovedMarkerCollapsed,
164+
designVariables.editedMovedMarkerExpanded,
165+
_animationProgress)),
166+
),
167+
],
168+
);
169+
73170
return ConstrainedBox(
74-
constraints: const BoxConstraints(maxWidth: widthCollapsed),
75-
child: Padding(
76-
padding: const EdgeInsetsDirectional.only(start: 5, end: 3),
77-
child: Icon(icon, size: iconSize,
78-
color: designVariables.editedMovedMarkerCollapsed)));
171+
constraints: BoxConstraints(maxWidth: _animation.value),
172+
child: Container(
173+
margin: EdgeInsetsDirectional.only(
174+
start: lerpDouble(5, 13, _animationProgress)!, end: 3),
175+
clipBehavior: Clip.hardEdge,
176+
decoration: BoxDecoration(
177+
borderRadius: BorderRadius.circular(3),
178+
color: Color.lerp(
179+
designVariables.editedMovedMarkerBg.withAlpha(0),
180+
designVariables.editedMovedMarkerBg,
181+
_animationProgress)),
182+
child: Padding(
183+
padding: EdgeInsetsDirectional.symmetric(
184+
horizontal: lerpDouble(0, 3, _animationProgress)!),
185+
child: marker),
186+
),
187+
);
79188
}
80189
}

0 commit comments

Comments
 (0)