Skip to content

Pan and zoom gallery demo - #25164

Merged
justinmc merged 70 commits into
flutter:masterfrom
justinmc:pan-and-zoom-gallery-demo
Apr 4, 2019
Merged

justinmc merged 70 commits into
flutter:masterfrom
justinmc:pan-and-zoom-gallery-demo

Conversation

@justinmc

@justinmc justinmc commented Dec 10, 2018 •

Copy link
Copy Markdown
Contributor

Adding a demo to the flutter_gallery app that shows how to do some basic canvas operations like panning, pinch to zoom, and hit detection.

Relevant issues: #8272, #12878

This PR won't close those issues, but after this goes in I'll open a new PR to move my code into a reusable widget that is more like what people are asking for.

@justinmc justinmc self-assigned this Dec 10, 2018
@justinmc

justinmc commented Dec 10, 2018 •

Copy link
Copy Markdown
Contributor Author

TODO:

  • Bring back the hex grid
  • Add tap to select hex tile
  • Zoom on the point where the user's fingers are
  • Rotation
  • Rotation around an arbitrary point?

@zoechi zoechi added the customer: gallery Relating to flutter/gallery repository. Please transfer non-framework issues there. label Dec 10, 2018
@zoechi zoechi added the c: contributor-productivity Team-specific productivity, code health, technical debt. label Dec 18, 2018
@justinmc
justinmc force-pushed the pan-and-zoom-gallery-demo branch 2 times, most recently from 03f0fc3 to c9ec50e Compare January 7, 2019 08:48
@zoechi zoechi mentioned this pull request Jan 9, 2019
@justinmc
justinmc force-pushed the pan-and-zoom-gallery-demo branch 8 times, most recently from 0155eb6 to 204ea54 Compare January 17, 2019 16:37

@HansMuller HansMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mostly superficial feedback about the code.

import 'pan_and_zoom_demo_transform_interaction.dart';

class PanAndZoomDemo extends StatefulWidget {
const PanAndZoomDemo({Key key}) : super(key: key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

({ Key key })

final BoardPainter painter = BoardPainter(
board: _board,
);
final Size screenSize = MediaQuery.of(context).size;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We need to get away from doing this kind of thing because it doesn't work on tablets or desktops. Better to use a LayoutBuilder to build this widget and then pass the "screen" size along as a parameter to this widget.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, I wasn't aware of that pattern but I like it a lot more.

I may revisit the size and visibleRect params when I bring this widget into Flutter. It might be better if this is as big as it can be and users can constrain the size with a parent widget.


final FloatingActionButton floatingActionButton = _board.selected == null
? FloatingActionButton(
onPressed: () => setState(() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

indent two spaces here and below

We usually reserve the => notation for one-liners that return a value

size: Size.infinite,
painter: painter,
),
visibleRect: Rect.fromLTWH(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe (someday) we should change the Rect.fromPoints parameters from Offset to OffsetBase so that you could write: visibleRect: Rect.fromPoints(-visibleSize / 2, visibleSize)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That would work for me!

// CustomPainter is what is passed to CustomPaint and actually draws the scene
// when its `paint` method is called.
class BoardPainter extends CustomPainter {
BoardPainter({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like should be const BorderPainter({ this.board }) etc

// Return a new matrix representing the given matrix after applying the given
// translation.
Matrix4 matrixTranslate(Matrix4 matrix, Offset translation) {
if (widget.disableTranslation) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

asserts that prove the parameter values aren't insane might be useful here.

}
void _onScaleUpdate(ScaleUpdateDetails details) {
widget.onScaleUpdate?.call(fromViewport(details.focalPoint, _transform));
double scale = _transform.getMaxScaleOnAxis();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can scale be zero? That's not going to work below, where foo / scale

// Return a new matrix representing the given matrix after applying the given
// scale transform.
Matrix4 matrixScale(Matrix4 matrix, double scale) {
if (widget.disableScale) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we done if scale is zero?

return matrix;
}

// Return the scene point underneath the viewport point given.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

underneath the viewport point given. => at the given viewport point

onDoubleTap: widget.onDoubleTap,
onLongPress: widget.onLongPress,
onLongPressUp: widget.onLongPressUp,
onVerticalDragDown: widget.onVerticalDragDown == null ? null : (DragDownDetails details) => widget.onVerticalDragDown(fromViewport(details.globalPosition - getOffset(context), _transform)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might be clearer to factor out a generic method that produces these closure values:

T _callbackValue<T>(T callback) {
  if (callback == null)
    return callback;
  return  (T details) => callback(fromViewport(details.globalPosition - getOffset(context), _transform));
}

And then:

onVerticalDragDown:  callbackValue<DragDownDetails>(widget.onVerticalDragDown),

This is assuming that you can define a local generic method. Otherwise you'd have to pass along more parameters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can't seem to get this to work. It won't let me write details.globalPosition when details is an unknown type. Unfortunately there is no common parent class or interface for all of these different Details objects.

When I first wrote this, I was also trying things like having TransformInteraction inherit from GestureDetector, but I couldn't get anything like that working either.

Any thing else to try?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, here's a version that should work:

ValueChanged<T> _callbackValue<T>(ValueChanged<T> callback) {
  if (callback == null)
    return callback;
  return (T details) => callback(fromViewport(details.globalPosition - getOffset(context), _transform));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I need your help implementing this. These callbacks have changed a bit so it needs to be slightly different again. I'll show you later.

static const double _kHexagonRadius = 32.0;
static const double _kHexagonMargin = 1.0;
static const int _kBorderRadius = 8;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

add code comment for what this is

board: _board,
);
final FloatingActionButton floatingActionButton = _board.selected == null
? FloatingActionButton(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The reader can realize there are 2 alternative FABs but can't tell what the intents are. It's also a bit hard to read. Put them into small functions and give them self-documenting names that describe their purposes.

// The scene is drawn by a CustomPaint, but user interaction is handled by
// the TransformInteraction parent widget.
return Scaffold(
appBar: AppBar(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

somewhat surprised you don't need a title here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guess not. I only added this because some smoke tests look for an app bar with a back button. Maybe I should add a title...

body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final Size size = Size(constraints.maxWidth, constraints.maxHeight);
final Size visibleSize = Size(size.width * 3, size.height * 2);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This line needs a comment. I assume this is just an arbitrary size of the board you're defining?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this is how much the user can see beyond the size of the widget by panning around before hitting a "wall". This size just feels nice for the demo. I'll add a comment.

@@ -10,6 +10,7 @@ export 'cupertino/cupertino.dart';
export 'fortnightly/fortnightly.dart';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what's the file above? is it intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fortnightly is a news app demo in the gallery. I didn't change anything about it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not fortnightly, I can't comment on the file but it's default.profraw

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh got it. Good catch! I must have accidentally added it. Will remove.

final Widget child;
final Size size;
final bool reset;
final Function onTapDown;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reuse the signatures of the recognizers instead of blank functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Whoops, good call.

this.onScaleEnd,
}) :
assert(!reset || onResetEnd != null, 'Must implement onResetEnd to use reset.');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What does null min/max scales, disableTranslations, initialTranslations etc etc mean?

@override TransformInteractionState createState() => TransformInteractionState();
}

enum GestureType {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this public? Document what this is for.

rotate,
}

class TransformInteractionState extends State<TransformInteraction> with TickerProviderStateMixin {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is this public? Who is expected to call its APIs? Document them

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I just realized that I made this public so that I can access it in a unit test. Is that valid idea or is there a better way?

}
});
}
void _onScaleEnd(ScaleEndDetails details) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

line

@justinmc
justinmc force-pushed the pan-and-zoom-gallery-demo branch from eae6e10 to a3fd435 Compare April 1, 2019 20:21
@justinmc

justinmc commented Apr 1, 2019

Copy link
Copy Markdown
Contributor Author

Ready for rereview.

@HansMuller one outstanding thing: I need some help using your _callbackValue function now that the surrounding code has changed.

And @xster: See comment #25164 (comment)

@Hixie

Hixie commented Apr 1, 2019 via email

Copy link
Copy Markdown
Contributor

@HansMuller HansMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is really close; just a handful of trivial feedback and a suggestion about factoring GestureTransformableState

if (q <= 0) {
rStart = -boardRadius - q;
rEnd = boardRadius;
} else if (q > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is just "else"

@required this.selected,
this.onTap,
}) : assert(color != null),
assert(selected != null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

one more space

@required this.boardPoint,
this.onColorSelection,
}) : assert(boardPoint != null),
super(key: key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

one more space

import 'transformations_demo_inertial_motion.dart';

// Empty function type for the onResetEnd parameter.
typedef _ResetEndCallback = void Function();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This isn't needed, you can use VoidCallback

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I didn't know about that.

this.onScaleStart,
this.onScaleUpdate,
this.onScaleEnd,
}) :

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sadly, conventional formatting for the asserts is:

    ....
    this.onScaleEnd,
}) : assert(child != null),
     assert(size != null),
     ....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I really had trouble with my assert indentation in this PR...

// original position of the child.
@immutable
class GestureTransformable extends StatefulWidget {
const GestureTransformable({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Widget classes should usually start with a Key key named parameter that's passed along via super(key: key)

}

// Get the offset of the current widget from the global screen coordinates.
static Offset getOffset(BuildContext context) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Eventually we'll have to explain when it's safe to call this (not at build time).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

At build time you wouldn't have a context, so is that enough discouragement from calling this at the wrong time?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

At build(BuildContext context) time is exactly when you might be tempted to call it.

At the moment this is just a private entry point within a demo. No need to worry about this yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh I see what you mean! I'll add a TODO so I remember this later.

// Translation is reversed (a positive translation moves the scene to the
// right, viewport to the left).
final Rect translationBoundaries = Rect.fromLTRB(
viewportBoundaries.right * -1 * scale,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe:

-scale * viewportBoundaries.right,

etc

import 'package:flutter/material.dart';
import 'package:vector_math/vector_math.dart' show Vector2;

// Provides calculations for an object moving with inertia and friction using

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Eventually a version of this should probably exist in the physics library.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now that you mention it I see we have friction_simulation.dart. I could probably reuse or extend that.


@override
Widget build (BuildContext context) {
if (widget.reset && _animationReset == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks like work that should be done at initStateTime() and in didUpdateWidget() (when the widget.reset flag changes).

@justinmc
justinmc force-pushed the pan-and-zoom-gallery-demo branch from 98c71ae to 89e38ab Compare April 2, 2019 20:53
@justinmc
justinmc force-pushed the pan-and-zoom-gallery-demo branch from a139c52 to 1375d67 Compare April 2, 2019 22:26
_controllerReset = AnimationController(
vsync: this,
);
if (widget.reset && _animationReset == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This method only runs once when the State is constructed so _animationReset has to be null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch.

@HansMuller HansMuller left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Hooray!

@justinmc
justinmc merged commit 3ada502 into flutter:master Apr 4, 2019
@justinmc
justinmc deleted the pan-and-zoom-gallery-demo branch April 4, 2019 18:59
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Aug 7, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

c: contributor-productivity Team-specific productivity, code health, technical debt. customer: gallery Relating to flutter/gallery repository. Please transfer non-framework issues there.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants