Commit 0608a02a authored by Adam Barth's avatar Adam Barth

Improve material ink response

This patch contains a number of improvements to the material ink response:

- The ink response now remains until you lift your finger
- When disappearing, the ink response now fades out
- The ink response is now the correct color (at least in the light theme)
- The ink response for IconButton now has a (circular) highlight
- The ink response for IconButton now repositions itself to be centered on the highlight

In addition, I've adjusted the various animation parameters to better match the
behavior of ink responses in the Java implementation of material.

Fixes #695
parent dc907830
......@@ -16,7 +16,9 @@ class InkResponse extends StatefulComponent {
this.onTap,
this.onDoubleTap,
this.onLongPress,
this.onHighlightChanged
this.onHighlightChanged,
this.containedInWell: false,
this.highlightShape: Shape.circle
}) : super(key: key);
final Widget child;
......@@ -24,16 +26,44 @@ class InkResponse extends StatefulComponent {
final GestureTapCallback onDoubleTap;
final GestureLongPressCallback onLongPress;
final ValueChanged<bool> onHighlightChanged;
final bool containedInWell;
final Shape highlightShape;
_InkResponseState createState() => new _InkResponseState<InkResponse>();
}
class _InkResponseState<T extends InkResponse> extends State<T> {
bool get containedInWell => false;
Set<InkSplash> _splashes;
InkSplash _currentSplash;
InkHighlight _lastHighlight;
void updateHighlight(bool value) {
if (value == (_lastHighlight != null && _lastHighlight.active))
return;
if (value) {
if (_lastHighlight == null) {
RenderBox referenceBox = context.findRenderObject();
assert(Material.of(context) != null);
_lastHighlight = Material.of(context).highlightAt(
referenceBox: referenceBox,
color: Theme.of(context).highlightColor,
shape: config.highlightShape,
onRemoved: () {
assert(_lastHighlight != null);
_lastHighlight = null;
}
);
} else {
_lastHighlight.activate();
}
} else {
_lastHighlight.deactivate();
}
if (config.onHighlightChanged != null)
config.onHighlightChanged(value != null);
}
void _handleTapDown(Point position) {
RenderBox referenceBox = context.findRenderObject();
......@@ -42,7 +72,8 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
splash = Material.of(context).splashAt(
referenceBox: referenceBox,
position: referenceBox.globalToLocal(position),
containedInWell: containedInWell,
color: Theme.of(context).splashColor,
containedInWell: config.containedInWell,
onRemoved: () {
if (_splashes != null) {
assert(_splashes.contains(splash));
......@@ -55,11 +86,13 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
_splashes ??= new Set<InkSplash>();
_splashes.add(splash);
_currentSplash = splash;
updateHighlight(true);
}
void _handleTap() {
_currentSplash?.confirm();
_currentSplash = null;
updateHighlight(false);
if (config.onTap != null)
config.onTap();
}
......@@ -67,6 +100,7 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
void _handleTapCancel() {
_currentSplash?.cancel();
_currentSplash = null;
updateHighlight(false);
}
void _handleDoubleTap() {
......@@ -92,9 +126,16 @@ class _InkResponseState<T extends InkResponse> extends State<T> {
_currentSplash = null;
}
assert(_currentSplash == null);
_lastHighlight?.dispose();
_lastHighlight = null;
super.deactivate();
}
void dependenciesChanged(Type affectedWidgetType) {
if (affectedWidgetType == Theme && _lastHighlight != null)
_lastHighlight.color = Theme.of(context).highlightColor;
}
Widget build(BuildContext context) {
final bool enabled = config.onTap != null || config.onDoubleTap != null || config.onLongPress != null;
return new GestureDetector(
......@@ -120,75 +161,15 @@ class InkWell extends InkResponse {
GestureTapCallback onTap,
GestureTapCallback onDoubleTap,
GestureLongPressCallback onLongPress,
this.onHighlightChanged
ValueChanged<bool> onHighlightChanged
}) : super(
key: key,
child: child,
onTap: onTap,
onDoubleTap: onDoubleTap,
onLongPress: onLongPress
onLongPress: onLongPress,
onHighlightChanged: onHighlightChanged,
containedInWell: true,
highlightShape: Shape.rectangle
);
final ValueChanged<bool> onHighlightChanged;
_InkWellState createState() => new _InkWellState();
}
class _InkWellState extends _InkResponseState<InkWell> {
bool get containedInWell => true;
InkHighlight _lastHighlight;
void updateHighlight(bool value) {
if (value == (_lastHighlight != null && _lastHighlight.active))
return;
if (value) {
if (_lastHighlight == null) {
RenderBox referenceBox = context.findRenderObject();
assert(Material.of(context) != null);
_lastHighlight = Material.of(context).highlightRectAt(
referenceBox: referenceBox,
color: Theme.of(context).highlightColor,
onRemoved: () {
assert(_lastHighlight != null);
_lastHighlight = null;
}
);
} else {
_lastHighlight.activate();
}
} else {
_lastHighlight.deactivate();
}
if (config.onHighlightChanged != null)
config.onHighlightChanged(value != null);
}
void _handleTapDown(Point position) {
super._handleTapDown(position);
updateHighlight(true);
}
void _handleTap() {
super._handleTap();
updateHighlight(false);
}
void _handleTapCancel() {
super._handleTapCancel();
updateHighlight(false);
}
void deactivate() {
_lastHighlight?.dispose();
_lastHighlight = null;
super.deactivate();
}
void dependenciesChanged(Type affectedWidgetType) {
if (affectedWidgetType == Theme && _lastHighlight != null)
_lastHighlight.color = Theme.of(context).highlightColor;
}
}
......@@ -58,10 +58,10 @@ abstract class MaterialInkController {
/// If containedInWell is true, then the splash will be sized to fit
/// the referenceBox, then clipped to it when drawn.
/// When the splash is removed, onRemoved will be invoked.
InkSplash splashAt({ RenderBox referenceBox, Point position, bool containedInWell, VoidCallback onRemoved });
InkSplash splashAt({ RenderBox referenceBox, Point position, Color color, bool containedInWell, VoidCallback onRemoved });
/// Begin a highlight, coincident with the referenceBox.
InkHighlight highlightRectAt({ RenderBox referenceBox, Color color, VoidCallback onRemoved });
InkHighlight highlightAt({ RenderBox referenceBox, Color color, Shape shape: Shape.rectangle, VoidCallback onRemoved });
/// Add an arbitrary InkFeature to this InkController.
void addInkFeature(InkFeature feature);
......@@ -156,14 +156,12 @@ class _MaterialState extends State<Material> {
}
}
const Duration _kHighlightFadeDuration = const Duration(milliseconds: 100);
const Duration _kHighlightFadeDuration = const Duration(milliseconds: 200);
const Duration _kUnconfirmedSplashDuration = const Duration(seconds: 1);
const double _kDefaultSplashRadius = 35.0; // logical pixels
const int _kSplashInitialAlpha = 0x30; // 0..255
const double _kSplashCanceledVelocity = 0.7; // logical pixels per millisecond
const double _kSplashConfirmedVelocity = 0.7; // logical pixels per millisecond
const double _kSplashConfirmedVelocity = 1.0; // logical pixels per millisecond
const double _kSplashInitialSize = 0.0; // logical pixels
const double _kSplashUnconfirmedVelocity = 0.2; // logical pixels per millisecond
class RenderInkFeatures extends RenderProxyBox implements MaterialInkController {
RenderInkFeatures({ RenderBox child, this.color }) : super(child);
......@@ -175,7 +173,13 @@ class RenderInkFeatures extends RenderProxyBox implements MaterialInkController
final List<InkFeature> _inkFeatures = <InkFeature>[];
InkSplash splashAt({ RenderBox referenceBox, Point position, bool containedInWell, VoidCallback onRemoved }) {
InkSplash splashAt({
RenderBox referenceBox,
Point position,
Color color,
bool containedInWell,
VoidCallback onRemoved
}) {
double radius;
if (containedInWell) {
radius = _getSplashTargetSize(referenceBox.size, position);
......@@ -186,8 +190,10 @@ class RenderInkFeatures extends RenderProxyBox implements MaterialInkController
renderer: this,
referenceBox: referenceBox,
position: position,
color: color,
targetRadius: radius,
clipToReferenceBox: containedInWell,
repositionToReferenceBox: !containedInWell,
onRemoved: onRemoved
);
addInkFeature(splash);
......@@ -202,11 +208,17 @@ class RenderInkFeatures extends RenderProxyBox implements MaterialInkController
return math.max(math.max(d1, d2), math.max(d3, d4)).ceilToDouble();
}
InkHighlight highlightRectAt({ RenderBox referenceBox, Color color, VoidCallback onRemoved }) {
InkHighlight highlightAt({
RenderBox referenceBox,
Color color,
Shape shape: Shape.rectangle,
VoidCallback onRemoved
}) {
_InkHighlight highlight = new _InkHighlight(
renderer: this,
referenceBox: referenceBox,
color: color,
shape: shape,
onRemoved: onRemoved
);
addInkFeature(highlight);
......@@ -303,45 +315,45 @@ class _InkSplash extends InkFeature implements InkSplash {
RenderInkFeatures renderer,
RenderBox referenceBox,
this.position,
this.color,
this.targetRadius,
this.clipToReferenceBox,
this.repositionToReferenceBox,
VoidCallback onRemoved
}) : super(renderer: renderer, referenceBox: referenceBox, onRemoved: onRemoved) {
_radius = new ValuePerformance<double>(
variable: new AnimatedValue<double>(
_kSplashInitialSize,
end: targetRadius,
curve: Curves.easeOut
),
duration: new Duration(milliseconds: (targetRadius / _kSplashUnconfirmedVelocity).floor())
)..addListener(_handleRadiusChange)
variable: new AnimatedValue<double>(_kSplashInitialSize, end: targetRadius),
duration: _kUnconfirmedSplashDuration
)..addListener(renderer.markNeedsPaint)
..play();
_alpha = new ValuePerformance<int>(
variable: new AnimatedIntValue(color.alpha, end: 0),
duration: _kHighlightFadeDuration
)..addListener(_handleAlphaChange);
}
final Point position;
final Color color;
final double targetRadius;
final bool clipToReferenceBox;
final bool repositionToReferenceBox;
double _pinnedRadius;
ValuePerformance<double> _radius;
ValuePerformance<int> _alpha;
void confirm() {
_updateVelocity(_kSplashConfirmedVelocity);
int duration = (targetRadius / _kSplashConfirmedVelocity).floor();
_radius.duration = new Duration(milliseconds: duration);
_radius.play();
_alpha.play();
}
void cancel() {
_updateVelocity(_kSplashCanceledVelocity);
_pinnedRadius = _radius.value;
_alpha.play();
}
void _updateVelocity(double velocity) {
int duration = (targetRadius / velocity).floor();
_radius.duration = new Duration(milliseconds: duration);
_radius.play();
}
void _handleRadiusChange() {
if (_radius.value == targetRadius)
void _handleAlphaChange() {
if (_alpha.value == _alpha.variable.end)
dispose();
else
renderer.markNeedsPaint();
......@@ -349,27 +361,31 @@ class _InkSplash extends InkFeature implements InkSplash {
void dispose() {
_radius.stop();
_alpha.stop();
super.dispose();
}
void paintFeature(Canvas canvas, Matrix4 transform) {
int alpha = (_kSplashInitialAlpha * (1.1 - (_radius.value / targetRadius))).floor();
Paint paint = new Paint()..color = new Color(alpha << 24); // TODO(ianh): in dark theme, this isn't very visible
double radius = _pinnedRadius == null ? _radius.value : _pinnedRadius;
Paint paint = new Paint()..color = color.withAlpha(_alpha.value);
Point center = position;
Offset originOffset = MatrixUtils.getAsTranslation(transform);
if (originOffset == null) {
canvas.save();
canvas.concat(transform.storage);
if (clipToReferenceBox)
canvas.clipRect(Point.origin & referenceBox.size);
canvas.drawCircle(position, radius, paint);
if (repositionToReferenceBox)
center = Point.lerp(center, Point.origin, _radius.progress);
canvas.drawCircle(center, _radius.value, paint);
canvas.restore();
} else {
if (clipToReferenceBox) {
canvas.save();
canvas.clipRect(originOffset.toPoint() & referenceBox.size);
}
canvas.drawCircle(position + originOffset, radius, paint);
if (repositionToReferenceBox)
center = Point.lerp(center, referenceBox.size.center(Point.origin), _radius.progress);
canvas.drawCircle(center + originOffset, _radius.value, paint);
if (clipToReferenceBox)
canvas.restore();
}
......@@ -381,15 +397,12 @@ class _InkHighlight extends InkFeature implements InkHighlight {
RenderInkFeatures renderer,
RenderBox referenceBox,
Color color,
this.shape,
VoidCallback onRemoved
}) : _color = color,
super(renderer: renderer, referenceBox: referenceBox, onRemoved: onRemoved) {
_alpha = new ValuePerformance<int>(
variable: new AnimatedIntValue(
0,
end: color.alpha,
curve: Curves.linear
),
variable: new AnimatedIntValue(0, end: color.alpha),
duration: _kHighlightFadeDuration
)..addListener(_handleAlphaChange)
..play();
......@@ -404,6 +417,8 @@ class _InkHighlight extends InkFeature implements InkHighlight {
renderer.markNeedsPaint();
}
final Shape shape;
bool get active => _active;
bool _active = true;
ValuePerformance<int> _alpha;
......@@ -430,16 +445,23 @@ class _InkHighlight extends InkFeature implements InkHighlight {
super.dispose();
}
void _paintHighlight(Canvas canvas, Rect rect, paint) {
if (shape == Shape.rectangle)
canvas.drawRect(rect, paint);
else
canvas.drawCircle(rect.center, _kDefaultSplashRadius, paint);
}
void paintFeature(Canvas canvas, Matrix4 transform) {
Paint paint = new Paint()..color = color.withAlpha(_alpha.value);
Offset originOffset = MatrixUtils.getAsTranslation(transform);
if (originOffset == null) {
canvas.save();
canvas.concat(transform.storage);
canvas.drawRect(Point.origin & referenceBox.size, paint);
_paintHighlight(canvas, Point.origin & referenceBox.size, paint);
canvas.restore();
} else {
canvas.drawRect(originOffset.toPoint() & referenceBox.size, paint);
_paintHighlight(canvas, originOffset.toPoint() & referenceBox.size, paint);
}
}
......
......@@ -10,6 +10,23 @@ import 'typography.dart';
enum ThemeBrightness { dark, light }
// Deriving these values is black magic. The spec claims that pressed buttons
// have a highlight of 0x66999999, but that's clearly wrong. The videos in the
// spec show that buttons have a composited highlight of #E1E1E1 on a background
// of #FAFAFA. Assuming that the highlight really has an opacity of 0x66, we can
// solve for the actual color of the highlight:
const Color _kLightThemeHighlightColor = const Color(0x66BCBCBC);
// The same video shows the splash compositing to #D7D7D7 on a background of
// #E1E1E1. Again, assuming the splash has an opacity of 0x66, we can solve for
// the actual color of the splash:
const Color _kLightThemeSplashColor = const Color(0x66C8C8C8);
// Unfortunately, a similar video isn't available for the dark theme, which
// means we assume the values in the spec are actually correct.
const Color _kDarkThemeHighlightColor = const Color(0x40CCCCCC);
const Color _kDarkThemeSplashColor = const Color(0x40CCCCCC);
class ThemeData {
ThemeData({
......@@ -27,7 +44,8 @@ class ThemeData {
// Some users want the pre-multiplied color, others just want the opacity.
hintColor = brightness == ThemeBrightness.dark ? const Color(0x42FFFFFF) : const Color(0x4C000000),
hintOpacity = brightness == ThemeBrightness.dark ? 0.26 : 0.30,
highlightColor = brightness == ThemeBrightness.dark ? const Color(0x42FFFFFF) : const Color(0x1F000000),
highlightColor = brightness == ThemeBrightness.dark ? _kDarkThemeHighlightColor : _kLightThemeHighlightColor,
splashColor = brightness == ThemeBrightness.dark ? _kDarkThemeSplashColor : _kLightThemeSplashColor,
text = brightness == ThemeBrightness.dark ? Typography.white : Typography.black {
assert(brightness != null);
......@@ -70,6 +88,7 @@ class ThemeData {
final Color dividerColor;
final Color hintColor;
final Color highlightColor;
final Color splashColor;
final double hintOpacity;
/// Text with a color that contrasts with the card and canvas colors.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment