Skip to content

Commit

Permalink
Implement swipe to reply gesture in Chat (#188, #134)
Browse files Browse the repository at this point in the history
Additionally:
- add haptic feedback when opening/closing timeline
- fix inability to scroll `Attachment`s by dragging
- impl `onPointerPanZoomUpdate` event handling in `Chat`
- make `DateTimeElement` to be sticky header in `Chat`
  • Loading branch information
krida2000 authored Nov 8, 2022
1 parent cba6b7b commit 202a548
Show file tree
Hide file tree
Showing 12 changed files with 736 additions and 787 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ All user visible changes to this project will be documented in this file. This p

[Diff](/../../compare/v0.1.0-alpha.7...v0.1.0-alpha.8) | [Milestone](/../../milestone/4)

### Added

- UI:
- Chat page:
- Swipe to reply gesture. ([#188], [#134])

### Changed

- UI:
Expand All @@ -27,7 +33,9 @@ All user visible changes to this project will be documented in this file. This p
- Web:
- Context menu not opening over video previews. ([#198], [#196])

[#134]: /../../issues/134
[#142]: /../../pull/142
[#188]: /../../pull/188
[#190]: /../../issues/190
[#191]: /../../pull/191
[#192]: /../../issues/192
Expand Down
17 changes: 14 additions & 3 deletions lib/ui/page/home/page/chat/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,21 @@ class ChatController extends GetxController {
/// Indicator whether there is an ongoing drag-n-drop at the moment.
final RxBool isDraggingFiles = RxBool(false);

/// [Timer] for discarding any vertical movement in a [SingleChildScrollView]
/// of [ChatItem]s when non-`null`.
/// Indicator whether any [ChatItem] is being dragged.
///
/// Indicates currently ongoing horizontal scroll of a view.
/// Used to discard any horizontal gestures while this is `true`.
final RxBool isItemDragged = RxBool(false);

/// Summarized [Offset] of an ongoing scroll.
Offset scrollOffset = Offset.zero;

/// Indicator whether an ongoing horizontal scroll is happening.
///
/// Used to discard any vertical gestures while this is `true`.
final RxBool isHorizontalScroll = RxBool(false);

/// [Timer] for discarding any vertical movement in a [FlutterListView] of
/// [ChatItem]s when non-`null`.
final Rx<Timer?> horizontalScrollTimer = Rx(null);

/// [GlobalKey] of the bottom bar.
Expand Down
168 changes: 110 additions & 58 deletions lib/ui/page/home/page/chat/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -269,35 +269,83 @@ class _ChatViewState extends State<ChatView>
body: Listener(
onPointerSignal: (s) {
if (s is PointerScrollEvent) {
// TODO: Use [PointerPanZoomUpdateEvent] here.
if (s.scrollDelta.dy.abs() < 3 &&
(s.scrollDelta.dx.abs() > 3 ||
c.horizontalScrollTimer.value != null)) {
if ((s.scrollDelta.dy.abs() < 3 &&
s.scrollDelta.dx.abs() > 3) ||
c.isHorizontalScroll.value) {
double value =
_animation.value + s.scrollDelta.dx / 100;
_animation.value = value.clamp(0, 1);

if (_animation.value == 0 ||
_animation.value == 1) {
_resetHorizontalScroll(c, 100.milliseconds);
_resetHorizontalScroll(c, 10.milliseconds);
} else {
_resetHorizontalScroll(c);
}
}
}
},
child: GestureDetector(
onPointerPanZoomUpdate: (s) {
if (c.scrollOffset.dx.abs() < 7 &&
c.scrollOffset.dy.abs() < 7) {
c.scrollOffset = c.scrollOffset.translate(
s.panDelta.dx.abs(),
s.panDelta.dy.abs(),
);
}
},
onPointerMove: (d) {
if (c.scrollOffset.dx.abs() < 7 &&
c.scrollOffset.dy.abs() < 7) {
c.scrollOffset = c.scrollOffset.translate(
d.delta.dx.abs(),
d.delta.dy.abs(),
);
}
},
child: RawGestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragUpdate: (d) {
double value = _animation.value - d.delta.dx / 100;
_animation.value = value.clamp(0, 1);
},
onHorizontalDragEnd: (d) {
if (_animation.value >= 0.5) {
_animation.forward();
} else {
_animation.reverse();
}
gestures: {
AllowMultipleHorizontalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
AllowMultipleHorizontalDragGestureRecognizer>(
() =>
AllowMultipleHorizontalDragGestureRecognizer(),
(AllowMultipleHorizontalDragGestureRecognizer
instance) {
instance.onUpdate = (d) {
if (!c.isItemDragged.value &&
c.scrollOffset.dy.abs() < 7 &&
c.scrollOffset.dx.abs() > 7) {
double value =
(_animation.value - d.delta.dx / 100)
.clamp(0, 1);

if (_animation.value != 1 && value == 1 ||
_animation.value != 0 && value == 0) {
HapticFeedback.selectionClick();
}

_animation.value = value.clamp(0, 1);
}
};

instance.onEnd = (d) async {
c.scrollOffset = Offset.zero;
if (!c.isItemDragged.value &&
_animation.value != 1 &&
_animation.value != 0) {
if (_animation.value >= 0.5) {
await _animation.forward();
HapticFeedback.selectionClick();
} else {
await _animation.reverse();
HapticFeedback.selectionClick();
}
}
};
},
)
},
child: Stack(
children: [
Expand All @@ -306,29 +354,11 @@ class _ChatViewState extends State<ChatView>
IgnorePointer(
child: ContextMenuInterceptor(child: Container()),
),
GestureDetector(
onHorizontalDragUpdate: PlatformUtils.isDesktop
? (d) {
double value =
_animation.value - d.delta.dx / 100;
_animation.value = value.clamp(0, 1);
}
: null,
onHorizontalDragEnd: PlatformUtils.isDesktop
? (d) {
if (_animation.value >= 0.5) {
_animation.forward();
} else {
_animation.reverse();
}
}
: null,
),
Obx(() {
return FlutterListView(
key: const Key('MessagesList'),
controller: c.listController,
physics: c.horizontalScrollTimer.value == null
physics: c.isHorizontalScroll.isFalse
? const BouncingScrollPhysics()
: const NeverScrollableScrollPhysics(),
delegate: FlutterListViewDelegate(
Expand All @@ -340,6 +370,8 @@ class _ChatViewState extends State<ChatView>
.elementAt(i)
.id
.toString(),
onItemSticky: (i) => c.elements.values
.elementAt(i) is DateTimeElement,
initIndex: c.initIndex,
initOffset: c.initOffset,
initOffsetBasedOnBottom: false,
Expand Down Expand Up @@ -513,6 +545,7 @@ class _ChatViewState extends State<ChatView>
onGallery: c.calculateGallery,
onResend: () => c.resendItem(e.value),
onEdit: () => c.editMessage(e.value),
onDrag: (d) => c.isItemDragged.value = d,
onFileTap: (a) => c.download(e.value, a),
onAttachmentError: () async {
await c.chat?.updateAttachments(e.value);
Expand Down Expand Up @@ -586,6 +619,7 @@ class _ChatViewState extends State<ChatView>
onCopy: c.copyText,
onGallery: c.calculateGallery,
onEdit: () => c.editMessage(element.note.value!.value),
onDrag: (d) => c.isItemDragged.value = d,
onForwardedTap: (id, chatId) {
if (chatId == c.id) {
c.animateTo(id);
Expand Down Expand Up @@ -833,8 +867,8 @@ class _ChatViewState extends State<ChatView>
mainAxisSize: MainAxisSize.min,
children: [
LayoutBuilder(builder: (context, constraints) {
bool grab =
127 * c.attachments.length > constraints.maxWidth - 16;
bool grab = (125 + 2) * c.attachments.length >
constraints.maxWidth - 16;

return ConditionalBackdropFilter(
condition: style.cardBlur > 0,
Expand Down Expand Up @@ -955,25 +989,28 @@ class _ChatViewState extends State<ChatView>
? SystemMouseCursors.grab
: MouseCursor.defer,
opaque: false,
child: SingleChildScrollView(
clipBehavior: Clip.none,
physics: grab
? null
: const NeverScrollableScrollPhysics(),
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment:
MainAxisAlignment.start,
children: c.attachments
.map(
(e) => _buildAttachment(
c,
e.value,
e.key,
),
)
.toList(),
child: ScrollConfiguration(
behavior: CustomScrollBehavior(),
child: SingleChildScrollView(
clipBehavior: Clip.none,
physics: grab
? null
: const NeverScrollableScrollPhysics(),
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment:
MainAxisAlignment.start,
children: c.attachments
.map(
(e) => _buildAttachment(
c,
e.value,
e.key,
),
)
.toList(),
),
),
),
),
Expand Down Expand Up @@ -1967,15 +2004,17 @@ class _ChatViewState extends State<ChatView>
/// Cancels a [_horizontalScrollTimer] and starts it again with the provided
/// [duration].
///
/// Defaults to 150 milliseconds if no [duration] is provided.
/// Defaults to 50 milliseconds if no [duration] is provided.
void _resetHorizontalScroll(ChatController c, [Duration? duration]) {
c.isHorizontalScroll.value = true;
c.horizontalScrollTimer.value?.cancel();
c.horizontalScrollTimer.value = Timer(duration ?? 150.milliseconds, () {
c.horizontalScrollTimer.value = Timer(duration ?? 50.milliseconds, () {
if (_animation.value >= 0.5) {
_animation.forward();
} else {
_animation.reverse();
}
c.isHorizontalScroll.value = false;
c.horizontalScrollTimer.value = null;
});
}
Expand Down Expand Up @@ -2045,3 +2084,16 @@ extension DateTimeToRelative on DateTime {
1721119;
}
}

/// [ScrollBehavior] for scrolling with every available [PointerDeviceKind]s.
class CustomScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet();
}

/// [GestureRecognizer] recognizing and allowing multiple horizontal drags.
class AllowMultipleHorizontalDragGestureRecognizer
extends HorizontalDragGestureRecognizer {
@override
void rejectGesture(int pointer) => acceptGesture(pointer);
}
71 changes: 71 additions & 0 deletions lib/ui/page/home/page/chat/widget/animated_offset.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright © 2022 IT ENGINEERING MANAGEMENT INC, <https://github.com/team113>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 as published by the
// Free Software Foundation, either version 3 of the License, or (at your
// option) any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for
// more details.
//
// You should have received a copy of the GNU Affero General Public License v3.0
// along with this program. If not, see
// <https://www.gnu.org/licenses/agpl-3.0.html>.

import 'package:flutter/material.dart';

/// Animated translation of the provided [child] on the [offset] changes.
class AnimatedOffset extends ImplicitlyAnimatedWidget {
const AnimatedOffset({
Key? key,
required this.offset,
required this.child,
Duration duration = const Duration(milliseconds: 250),
Curve curve = Curves.linear,
void Function()? onEnd,
}) : super(key: key, curve: curve, duration: duration, onEnd: onEnd);

/// [Offset] to apply to the [child].
final Offset offset;

/// [Widget] to offset.
final Widget child;

@override
ImplicitlyAnimatedWidgetState<AnimatedOffset> createState() =>
_AnimatedOffsetState();
}

/// State of an [AnimatedOffset] maintaining its [_animation] and [_transform].
class _AnimatedOffsetState
extends ImplicitlyAnimatedWidgetState<AnimatedOffset> {
/// [Animation] animating the [Offset] changes.
late Animation<Offset> _animation;

/// [Tween] to drive the [_animation] with.
Tween<Offset>? _transform;

@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_transform = visitor(
_transform,
widget.offset,
(value) => Tween<Offset>(begin: value as Offset),
) as Tween<Offset>?;
}

@override
void didUpdateTweens() => _animation = animation.drive(_transform!);

@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (_, child) =>
Transform.translate(offset: _animation.value, child: child!),
child: widget.child,
);
}
}
Loading

0 comments on commit 202a548

Please sign in to comment.