Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/flutter/lib/gestures.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export 'src/gestures/drag.dart';
export 'src/gestures/drag_details.dart';
export 'src/gestures/eager.dart';
export 'src/gestures/events.dart';
export 'src/gestures/force_press.dart';
export 'src/gestures/hit_test.dart';
export 'src/gestures/long_press.dart';
export 'src/gestures/lsq_solver.dart';
Expand Down
12 changes: 10 additions & 2 deletions packages/flutter/lib/src/gestures/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ class PointerAddedEvent extends PointerEvent {
int device = 0,
Offset position = Offset.zero,
bool obscured = false,
double pressure = 0.0,
double pressureMin = 1.0,
double pressureMax = 1.0,
double distance = 0.0,
Expand All @@ -348,6 +349,7 @@ class PointerAddedEvent extends PointerEvent {
device: device,
position: position,
obscured: obscured,
pressure: pressure,
pressureMin: pressureMin,
pressureMax: pressureMax,
distance: distance,
Expand All @@ -372,6 +374,7 @@ class PointerRemovedEvent extends PointerEvent {
PointerDeviceKind kind = PointerDeviceKind.touch,
int device = 0,
bool obscured = false,
double pressure = 0.0,
double pressureMin = 1.0,
double pressureMax = 1.0,
double distanceMax = 0.0,
Expand All @@ -383,11 +386,12 @@ class PointerRemovedEvent extends PointerEvent {
device: device,
position: null,
obscured: obscured,
pressure: pressure,
pressureMin: pressureMin,
pressureMax: pressureMax,
distanceMax: distanceMax,
radiusMin: radiusMin,
radiusMax: radiusMax
radiusMax: radiusMax,
);
}

Expand All @@ -410,6 +414,7 @@ class PointerHoverEvent extends PointerEvent {
Offset delta = Offset.zero,
int buttons = 0,
bool obscured = false,
double pressure = 0.0,
double pressureMin = 1.0,
double pressureMax = 1.0,
double distance = 0.0,
Expand All @@ -431,6 +436,7 @@ class PointerHoverEvent extends PointerEvent {
buttons: buttons,
down: false,
obscured: obscured,
pressure: pressure,
pressureMin: pressureMin,
pressureMax: pressureMax,
distance: distance,
Expand Down Expand Up @@ -567,7 +573,7 @@ class PointerUpEvent extends PointerEvent {
Offset position = Offset.zero,
int buttons = 0,
bool obscured = false,
double pressure = 1.0,
double pressure = 0.0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we make (though minor) breaking changes here and for pointer added, we should announce it in flutter-dev

Copy link
Contributor Author

@jslavitz jslavitz Dec 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

double pressureMin = 1.0,
double pressureMax = 1.0,
double distance = 0.0,
Expand Down Expand Up @@ -616,6 +622,7 @@ class PointerCancelEvent extends PointerEvent {
Offset position = Offset.zero,
int buttons = 0,
bool obscured = false,
double pressure = 0.0,
double pressureMin = 1.0,
double pressureMax = 1.0,
double distance = 0.0,
Expand All @@ -636,6 +643,7 @@ class PointerCancelEvent extends PointerEvent {
buttons: buttons,
down: false,
obscured: obscured,
pressure: pressure,
pressureMin: pressureMin,
pressureMax: pressureMax,
distance: distance,
Expand Down
308 changes: 308 additions & 0 deletions packages/flutter/lib/src/gestures/force_press.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:ui' show Offset;

import 'package:flutter/foundation.dart';

import 'arena.dart';
import 'constants.dart';
import 'events.dart';
import 'recognizer.dart';

enum _ForceState {
// No pointer has touched down and the detector is ready for a pointer down to occur.
ready,

// A pointer has touched down, but a force press gesture has not yet been detected.
possible,

// A pointer is down and a force press gesture has been detected. However, if
// the ForcePressGestureRecognizer is the only recognizer in the arena, thus
// accepted as soon as the gesture state is possible, the gesture will not
// yet have started.
accepted,

// A pointer is down and the gesture has started, ie. the pressure of the pointer
// has just become greater than the ForcePressGestureRecognizer.startPressure.
started,

// A pointer is down and the pressure of the pointer has just become greater
// than the ForcePressGestureRecognizer.peakPressure. Even after a pointer
// crosses this threshold, onUpdate callbacks will still be sent.
peaked,
}

/// Details object for callbacks that use [GestureForcePressStartCallback],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you reached start, you should already have a pressure value no?

Copy link
Contributor Author

@jslavitz jslavitz Nov 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes? But the pressure value will be sent in onUpdate. In this way, onStart and the first onUpdate will sent in the same frames. It's the way it's done for monodrag.dart so I thought it would make sense to replicate the behavior on this recognizer. I guess it would still make sense to include the pressure value for the onStart callback even though it will be the same as the one sent for onUpdate?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, didn't realize it was on the same frame. Sounds ok then.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to just add the pressure into all the calls – so you don't have to have an onUpdate to get the pressure if you really only want the onStart call.

/// [GestureForcePressPeakCallback], [GestureForcePressEndCallback] or
/// [GestureForcePressUpdateCallback].
///
/// See also:
///
/// * [ForcePressGestureRecognizer.onStart], [ForcePressGestureRecognizer.onPeak],
/// [ForcePressGestureRecognizer.onEnd], and [ForcePressGestureRecognizer.onUpdate]
/// which use [ForcePressDetails].
/// * [ForcePressUpdateDetails], the details for [ForcePressUpdateCallback].
class ForcePressDetails {
/// Creates details for a [GestureForcePressStartCallback],
/// [GestureForcePressPeakCallback] or [GestureForcePressEndCallback].
///
/// The [globalPosition] argument must not be null.
ForcePressDetails({
@required this.globalPosition,
@required this.pressure,
}) : assert(globalPosition != null),
assert(pressure != null);

/// The global position at which the function was called.
final Offset globalPosition;

/// The pressure of the pointer on the screen.
final double pressure;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine to do it in a different PR but I was saying above, there will be cases where users will use the pressure double value before start is triggered. See on the home screen, when you barely force press an icon, it'll visually respond before you trigger the start.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought start was supposed to be the pressure at which it begins to visually respond?

/// Signature used by a [ForcePressGestureRecognizer] for when a pointer has
/// pressed with at least [ForcePressGestureRecognizer.startPressure].
typedef GestureForcePressStartCallback = void Function(ForcePressDetails details);

/// Signature used by [ForcePressGestureRecognizer] for when a pointer that has
/// pressed with at least [ForcePressGestureRecognizer.peakPressure].
typedef GestureForcePressPeakCallback = void Function(ForcePressDetails details);

/// Signature used by [ForcePressGestureRecognizer] during the frames
/// after the triggering of a [ForcePressGestureRecognizer.onStart] callback.
typedef GestureForcePressUpdateCallback = void Function(ForcePressDetails details);

/// Signature for when the pointer that previously triggered a
/// [ForcePressGestureRecognizer.onStart] callback is no longer in contact
/// with the screen.
typedef GestureForcePressEndCallback = void Function(ForcePressDetails details);

/// Signature used by [ForcePressGestureRecognizer] for interpolating the raw
/// device pressure to a value in the range [0, 1] given the device's pressure
/// min and pressure max.
typedef GestureForceInterpolation = double Function(double pressureMin, double pressureMax, double pressure);

/// Recognizes a force press on devices that have force sensors.
///
/// Only the force from a single pointer is used to invoke events. A tap
/// recognizer will win against this recognizer on pointer up as long as the
/// pointer has not pressed with a force greater than
/// [ForcePressGestureRecognizer.startPressure]. A long press recognizer will
/// win when the press down time exceeds the threshold time as long as the
/// pointer's pressure was never greater than
/// [ForcePressGestureRecognizer.startPressure] in that duration.
///
/// As of November, 2018 iPhone devices of generation 6S and higher have
/// force touch functionality, with the exception of the iPhone XR. In addition,
/// a small handful of Android devices have this functionality as well.
///
/// Reported pressure will always be in the range [0.0, 1.0], where 1.0 is
/// maximum pressure and 0.0 is minimum pressure. If using a non-linear
/// interpolation equation, the pressure reported will correspond with the
/// custom curve. (ie. if the interpolation maps t(0.5) -> 0.1, a value of 0.1
/// will be reported at a device pressure value of 0.5).
///
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @xster: when reviewing, please look out for trailing blank lines on comments such as here

class ForcePressGestureRecognizer extends OneSequenceGestureRecognizer {
/// Creates a force press gesture recognizer.
///
/// The [startPressure] defaults to 0.4, and [peakPressure] defaults to 0.85
/// where a value of 0.0 is no pressure and a value of 1.0 is maximum pressure.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both UITouch and our PointerEvents scale 1.0 to mean an average touch. We shouldn't create a different scale system here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I don't know if this is the wrong approach, but the events that come in off the device actually have pressures in the range of [0, 6.66], however, I interpolate the recorded values into the range [0,1], so that functionality will work the same across all devices even in this range is different. Maybe it would make more sense to change the default pointer pressure max on these events to 6.66?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ya, we're in a bit of a pickle here. Unfortunately neither solution will be very convenient.

Linearly interpolating is pretty convenient today but the apple API makes no contractual promises with the range or the curve of the values. Neither does Android and things are even more likely to be the wild west there. Trying to shield our users from the rawness of it will likely reach a limit sooner or later.

Just referring to some arbitrary absolute value would be crappy too since the user has to go capture the device's min/max range first before constructing this recognizer.

I think we have 2 options:
1- Make getting the device's capability super statically easy (like https://developer.android.com/reference/android/view/InputDevice.MotionRange on Android).
2- Composability is likely our friend. Let there be 2 customizable predicate function arguments where the user can decide whether start and peak are reached given the device's min and max pressure. That function itself can actually have a const default function that maps to a reasonable value but it's overridable.

@Hixie for opinions and prior art too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We definitely need to address this before merging.

To @Hixie's comment. We don't really need to worry about subjectively interpreting the 0-1 range. The Android and iOS APIs already contractually assign meanings to those bounds. We can just pass them directly.

There are no higher bound API definitions. I would prefer letting start and peak be predicates in those cases. The user may want to curve differently if the device can go to 3 vs if it can go to 10. We don't have that future information and shouldn't lock our users to our present interpretation.

///
/// [startPressure], [peakPressure] and [interpolation] must not be null.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xster while i'm at it: when reviewing, please look out for sentences that don't start with a capital letter. This would be better written as: The arguments [...], [...], and [...] must not be null.

/// [peakPressure] must be greater than [startPressure]. [interpolation] must
/// always return a value in the range [0.0, 1.0] where
/// pressureMin <= pressure <= pressureMax.
ForcePressGestureRecognizer({
this.startPressure = 0.4,
this.peakPressure = 0.85,
this.interpolation = _inverseLerp,
Object debugOwner,
}) : assert(startPressure != null),
assert(peakPressure != null),
assert(interpolation != null),
assert(peakPressure > startPressure),
super(debugOwner: debugOwner);

/// A pointer is in contact with the screen and has just pressed with a force
/// exceeding the [startPressure]. Consequently, if there were other gesture
/// detectors, only the force press gesture will be detected and all others
/// will be rejected.
///
/// The position of the pointer is provided in the callback's `details`
/// argument, which is a [ForcePressDetails] object.
GestureForcePressStartCallback onStart;

/// A pointer is in contact with the screen and is either moving on the plane
/// of the screen, pressing the screen with varying forces or both
/// simultaneously.
///
/// This callback will be invoked for every pointer event after the invocation
/// of [onStart] and/or [onPeak] and before the invocation of [onEnd], no
/// matter what the pressure is during this time period. The position and
/// pressure of the pointer is provided in the callback's `details` argument,
/// which is a [ForcePressUpdateDetails] object.
GestureForcePressUpdateCallback onUpdate;

/// A pointer is in contact with the screen and has just pressed with a force
/// exceeding the [peakPressure]. This is an arbitrary second level action
/// threshold and isn't necessarily the maximum possible device pressure
/// (which is 1.0).
///
/// The position of the pointer is provided in the callback's `details`
/// argument, which is a [ForcePressDetails] object.
GestureForcePressPeakCallback onPeak;

/// A pointer is no longer in contact with the screen.
///
/// The position of the pointer is provided in the callback's `details`
/// argument, which is a [ForcePressDetails] object.
GestureForcePressEndCallback onEnd;

/// The pressure of the press required to initiate a force press.
///
/// A value of 0.0 is no pressure, and 1.0 is maximum pressure.
final double startPressure;

/// The pressure of the press required to peak a force press.
///
/// A value of 0.0 is no pressure, and 1.0 is maximum pressure. This value
/// must be greater than [startPressure].
final double peakPressure;

/// The function used to convert the raw device pressure values into a value
/// in the range [0, 1].
///
/// The function takes in the device's min, max and raw touch
/// pressure and returns a value in the range [0.0, 1.0] denoting the
/// interpolated touch pressure.
///
/// This function must always return values in the range [0, 1] when
/// pressureMin <= pressure <= pressureMax.
///
/// By default, the the function is a simple linear interpolation, however,
/// changing the function could be useful to accommodate variations in the way
/// different devices respond to pressure, change how animations from pressure
/// feedback are rendered or for other custom functionality.
///
/// For example, an ease in curve can be used to determine the interpolated
/// value:
///
/// ```dart
/// static double interpolateWithEasing(double min, double max, double t) {
/// final double lerp = (t - min) / (max - min);
/// return Curves.easeIn.transform(lerp);
/// }
/// ```
final GestureForceInterpolation interpolation;

Offset _lastPosition;
double _lastPressure;
_ForceState _state = _ForceState.ready;

@override
void addPointer(PointerEvent event) {
startTrackingPointer(event.pointer);
if (_state == _ForceState.ready) {
_state = _ForceState.possible;
_lastPosition = event.position;
}
}

@override
void handleEvent(PointerEvent event) {
assert(_state != _ForceState.ready);
// A static pointer with changes in pressure creates PointerMoveEvent events.
if (event is PointerMoveEvent || event is PointerDownEvent) {
final double pressure = interpolation(event.pressureMin, event.pressureMax, event.pressure);
assert(pressure.isNaN ? true : (pressure <= 1.0 && pressure >= 0.0));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally speaking I would recommend using asserts that only use || and &&, and ranges should be checked low-then-high for clarity, so this would be better written as:

assert(pressure.isNaN || (pressure >= 0.0 && pressure <= 1.0));

cc @xster who reviewed

_lastPosition = event.position;
_lastPressure = pressure;

if (_state == _ForceState.possible) {
if (pressure > startPressure) {
_state = _ForceState.started;
resolve(GestureDisposition.accepted);
} else if (event.delta.distanceSquared > kTouchSlop) {
resolve(GestureDisposition.rejected);
}
}
// In case this is the only gesture detector we still don't want to start
// the gesture until the pressure is greater than the startPressure.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this means. If line 181 ran, wouldn't this always always run? Maybe I'm missing something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wouldn't because I set the state to started before line 181 runs. I'm navigating the case where the gesture has been accepted, but the pressure isn't greater than the start pressure. That's the scenario in which this line will run.

if (pressure > startPressure && _state == _ForceState.accepted) {
_state = _ForceState.started;
if (onStart != null) {
invokeCallback<void>('onStart', () => onStart(ForcePressDetails(
pressure: pressure,
globalPosition: _lastPosition,
)));
}
}
if (onPeak != null && pressure > peakPressure &&
(_state == _ForceState.started)) {
_state = _ForceState.peaked;
if (onPeak != null) {
invokeCallback<void>('onPeak', () => onPeak(ForcePressDetails(
pressure: pressure,
globalPosition: event.position,
)));
}
}
if (onUpdate != null &&
(_state == _ForceState.started || _state == _ForceState.peaked)) {
if (onUpdate != null) {
invokeCallback<void>('onUpdate', () => onUpdate(ForcePressDetails(
pressure: pressure,
globalPosition: event.position,
)));
}
}
}
stopTrackingIfPointerNoLongerDown(event);
}

@override
void acceptGesture(int pointer) {
if (_state == _ForceState.possible)
_state = _ForceState.accepted;

if (onStart != null && _state == _ForceState.started) {
invokeCallback<void>('onStart', () => onStart(ForcePressDetails(
pressure: _lastPressure,
globalPosition: _lastPosition,
)));
}
}

@override
void didStopTrackingLastPointer(int pointer) {
final bool wasAccepted = _state == _ForceState.started || _state == _ForceState.peaked;
if (_state == _ForceState.possible) {
resolve(GestureDisposition.rejected);
return;
}
if (wasAccepted && onEnd != null) {
if (onEnd != null) {
invokeCallback<void>('onEnd', () => onEnd(ForcePressDetails(
pressure: 0.0,
globalPosition: _lastPosition,
)));
}
}
_state = _ForceState.ready;
}

@override
void rejectGesture(int pointer) {
stopTrackingPointer(pointer);
didStopTrackingLastPointer(pointer);
}

static double _inverseLerp(double min, double max, double t) {
return (t - min) / (max - min);
}

@override
String get debugDescription => 'force press';
}
Loading