Unverified Commit 3ada5028 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Pan and zoom gallery demo (#25164)

Adds the "2D Transformations" demo to the gallery, which shows how to do things such as navigate around a map a la Google Maps, or show a full screen zoomable photo.  The idea is to abstract this code into a first class widget soon.
parent 423cf223
......@@ -12,5 +12,6 @@ export 'images_demo.dart';
export 'material/material.dart';
export 'pesto_demo.dart';
export 'shrine_demo.dart';
export 'transformations/transformations_demo.dart';
export 'typography_demo.dart';
export 'video_demo.dart';
import 'dart:ui' show Vertices;
import 'package:flutter/material.dart';
import 'transformations_demo_board.dart';
import 'transformations_demo_edit_board_point.dart';
import 'transformations_demo_gesture_transformable.dart';
class TransformationsDemo extends StatefulWidget {
const TransformationsDemo({ Key key }) : super(key: key);
static const String routeName = '/transformations';
@override _TransformationsDemoState createState() => _TransformationsDemoState();
}
class _TransformationsDemoState extends State<TransformationsDemo> {
// The radius of a hexagon tile in pixels.
static const double _kHexagonRadius = 32.0;
// The margin between hexagons.
static const double _kHexagonMargin = 1.0;
// The radius of the entire board in hexagons, not including the center.
static const int _kBoardRadius = 8;
bool _reset = false;
Board _board = Board(
boardRadius: _kBoardRadius,
hexagonRadius: _kHexagonRadius,
hexagonMargin: _kHexagonMargin,
);
@override
Widget build (BuildContext context) {
final BoardPainter painter = BoardPainter(
board: _board,
);
// The scene is drawn by a CustomPaint, but user interaction is handled by
// the GestureTransformable parent widget.
return Scaffold(
appBar: AppBar(),
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Draw the scene as big as is available, but allow the user to
// translate beyond that to a visibleSize that's a bit bigger.
final Size size = Size(constraints.maxWidth, constraints.maxHeight);
final Size visibleSize = Size(size.width * 3, size.height * 2);
return GestureTransformable(
reset: _reset,
onResetEnd: () {
setState(() {
_reset = false;
});
},
child: CustomPaint(
painter: painter,
),
boundaryRect: Rect.fromLTWH(
-visibleSize.width / 2,
-visibleSize.height / 2,
visibleSize.width,
visibleSize.height,
),
// Center the board in the middle of the screen. It's drawn centered
// at the origin, which is the top left corner of the
// GestureTransformable.
initialTranslation: Offset(size.width / 2, size.height / 2),
onTapUp: _onTapUp,
size: size,
);
},
),
floatingActionButton: _board.selected == null ? resetButton : editButton,
);
}
FloatingActionButton get resetButton {
return FloatingActionButton(
onPressed: () {
setState(() {
_reset = true;
});
},
tooltip: 'Reset Transform',
backgroundColor: Theme.of(context).primaryColor,
child: const Icon(Icons.home),
);
}
FloatingActionButton get editButton {
return FloatingActionButton(
onPressed: () {
if (_board.selected == null) {
return;
}
showModalBottomSheet<Widget>(context: context, builder: (BuildContext context) {
return Container(
width: double.infinity,
height: 150,
padding: const EdgeInsets.all(12.0),
child: EditBoardPoint(
boardPoint: _board.selected,
onColorSelection: (Color color) {
setState(() {
_board = _board.copyWithBoardPointColor(_board.selected, color);
Navigator.pop(context);
});
},
),
);
});
},
tooltip: 'Edit Tile',
child: const Icon(Icons.edit),
);
}
void _onTapUp(TapUpDetails details) {
final Offset scenePoint = details.globalPosition;
final BoardPoint boardPoint = _board.pointToBoardPoint(scenePoint);
setState(() {
_board = _board.copyWithSelected(boardPoint);
});
}
}
// CustomPainter is what is passed to CustomPaint and actually draws the scene
// when its `paint` method is called.
class BoardPainter extends CustomPainter {
const BoardPainter({
this.board,
});
final Board board;
@override
void paint(Canvas canvas, Size size) {
void drawBoardPoint(BoardPoint boardPoint) {
final Color color = boardPoint.color.withOpacity(
board.selected == boardPoint ? 0.2 : 1.0,
);
final Vertices vertices = board.getVerticesForBoardPoint(boardPoint, color);
canvas.drawVertices(vertices, BlendMode.color, Paint());
}
board.forEach(drawBoardPoint);
}
// We should repaint whenever the board changes, such as board.selected.
@override
bool shouldRepaint(BoardPainter oldDelegate) {
return oldDelegate.board != board;
}
}
import 'dart:collection' show IterableMixin;
import 'dart:math';
import 'dart:ui' show Vertices;
import 'package:flutter/material.dart' hide Gradient;
import 'package:vector_math/vector_math_64.dart' show Vector3;
// The entire state of the hex board and abstraction to get information about
// it. Iterable so that all BoardPoints on the board can be iterated over.
@immutable
class Board extends Object with IterableMixin<BoardPoint> {
Board({
@required this.boardRadius,
@required this.hexagonRadius,
@required this.hexagonMargin,
this.selected,
List<BoardPoint> boardPoints,
}) : assert(boardRadius > 0),
assert(hexagonRadius > 0),
assert(hexagonMargin >= 0) {
// Set up the positions for the center hexagon where the entire board is
// centered on the origin.
// Start point of hexagon (top vertex).
final Point<double> hexStart = Point<double>(0, -hexagonRadius);
final double hexagonRadiusPadded = hexagonRadius - hexagonMargin;
final double centerToFlat = sqrt(3) / 2 * hexagonRadiusPadded;
positionsForHexagonAtOrigin.addAll(<Offset>[
Offset(hexStart.x, hexStart.y),
Offset(hexStart.x + centerToFlat, hexStart.y + 0.5 * hexagonRadiusPadded),
Offset(hexStart.x + centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x + centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x, hexStart.y + 2 * hexagonRadiusPadded),
Offset(hexStart.x, hexStart.y + 2 * hexagonRadiusPadded),
Offset(hexStart.x - centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x - centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x - centerToFlat, hexStart.y + 0.5 * hexagonRadiusPadded),
]);
if (boardPoints != null) {
_boardPoints.addAll(boardPoints);
} else {
// Generate boardPoints for a fresh board.
BoardPoint boardPoint = _getNextBoardPoint(null);
while (boardPoint != null) {
_boardPoints.add(boardPoint);
boardPoint = _getNextBoardPoint(boardPoint);
}
}
}
final int boardRadius; // Number of hexagons from center to edge.
final double hexagonRadius; // Pixel radius of a hexagon (center to vertex).
final double hexagonMargin; // Margin between hexagons.
final List<Offset> positionsForHexagonAtOrigin = <Offset>[];
final BoardPoint selected;
final List<BoardPoint> _boardPoints = <BoardPoint>[];
@override
Iterator<BoardPoint> get iterator => _BoardIterator(_boardPoints);
// For a given q axial coordinate, get the range of possible r values
// See the definition of BoardPoint for more information about hex grids and
// axial coordinates.
_Range _getRRangeForQ(int q) {
int rStart;
int rEnd;
if (q <= 0) {
rStart = -boardRadius - q;
rEnd = boardRadius;
} else {
rEnd = boardRadius - q;
rStart = -boardRadius;
}
return _Range(rStart, rEnd);
}
// Get the BoardPoint that comes after the given BoardPoint. If given null,
// returns the origin BoardPoint. If given BoardPoint is the last, returns
// null.
BoardPoint _getNextBoardPoint (BoardPoint boardPoint) {
// If before the first element.
if (boardPoint == null) {
return BoardPoint(-boardRadius, 0);
}
final _Range rRange = _getRRangeForQ(boardPoint.q);
// If at or after the last element.
if (boardPoint.q >= boardRadius && boardPoint.r >= rRange.max) {
return null;
}
// If wrapping from one q to the next.
if (boardPoint.r >= rRange.max) {
return BoardPoint(boardPoint.q + 1, _getRRangeForQ(boardPoint.q + 1).min);
}
// Otherwise we're just incrementing r.
return BoardPoint(boardPoint.q, boardPoint.r + 1);
}
// Check if the board point is actually on the board.
bool _validateBoardPoint(BoardPoint boardPoint) {
const BoardPoint center = BoardPoint(0, 0);
final int distanceFromCenter = getDistance(center, boardPoint);
return distanceFromCenter <= boardRadius;
}
// Get the distance between two BoardPoins.
static int getDistance(BoardPoint a, BoardPoint b) {
final Vector3 a3 = a.cubeCoordinates;
final Vector3 b3 = b.cubeCoordinates;
return
((a3.x - b3.x).abs() + (a3.y - b3.y).abs() + (a3.z - b3.z).abs()) ~/ 2;
}
// Return the q,r BoardPoint for a point in the scene, where the origin is in
// the center of the board in both coordinate systems. If no BoardPoint at the
// location, return null.
BoardPoint pointToBoardPoint(Offset point) {
final BoardPoint boardPoint = BoardPoint(
((sqrt(3) / 3 * point.dx - 1 / 3 * point.dy) / hexagonRadius).round(),
((2 / 3 * point.dy) / hexagonRadius).round(),
);
if (!_validateBoardPoint(boardPoint)) {
return null;
}
return _boardPoints.firstWhere((BoardPoint boardPointI) {
return boardPointI.q == boardPoint.q && boardPointI.r == boardPoint.r;
});
}
// Return a scene point for the center of a hexagon given its q,r point.
Point<double> boardPointToPoint(BoardPoint boardPoint) {
return Point<double>(
sqrt(3) * hexagonRadius * boardPoint.q + sqrt(3) / 2 * hexagonRadius * boardPoint.r,
1.5 * hexagonRadius * boardPoint.r,
);
}
// Get Vertices that can be drawn to a Canvas for the given BoardPoint.
Vertices getVerticesForBoardPoint(BoardPoint boardPoint, Color color) {
final Point<double> centerOfHexZeroCenter = boardPointToPoint(boardPoint);
final List<Offset> positions = positionsForHexagonAtOrigin.map((Offset offset) {
return offset.translate(centerOfHexZeroCenter.x, centerOfHexZeroCenter.y);
}).toList();
return Vertices(
VertexMode.triangleFan,
positions,
colors: List<Color>.filled(positions.length, color),
);
}
// Return a new board with the given BoardPoint selected.
Board copyWithSelected(BoardPoint boardPoint) {
if (selected == boardPoint) {
return this;
}
final Board nextBoard = Board(
boardRadius: boardRadius,
hexagonRadius: hexagonRadius,
hexagonMargin: hexagonMargin,
selected: boardPoint,
boardPoints: _boardPoints,
);
return nextBoard;
}
// Return a new board where boardPoint has the given color.
Board copyWithBoardPointColor(BoardPoint boardPoint, Color color) {
final BoardPoint nextBoardPoint = boardPoint.copyWithColor(color);
final int boardPointIndex = _boardPoints.indexWhere((BoardPoint boardPointI) =>
boardPointI.q == boardPoint.q && boardPointI.r == boardPoint.r
);
if (elementAt(boardPointIndex) == boardPoint && boardPoint.color == color) {
return this;
}
final List<BoardPoint> nextBoardPoints = List<BoardPoint>.from(_boardPoints);
nextBoardPoints[boardPointIndex] = nextBoardPoint;
final BoardPoint selectedBoardPoint = boardPoint == selected
? nextBoardPoint
: selected;
return Board(
boardRadius: boardRadius,
hexagonRadius: hexagonRadius,
hexagonMargin: hexagonMargin,
selected: selectedBoardPoint,
boardPoints: nextBoardPoints,
);
}
}
class _BoardIterator extends Iterator<BoardPoint> {
_BoardIterator(this.boardPoints);
final List<BoardPoint> boardPoints;
int currentIndex;
@override
BoardPoint current;
@override
bool moveNext() {
if (currentIndex == null) {
currentIndex = 0;
} else {
currentIndex++;
}
if (currentIndex >= boardPoints.length) {
current = null;
return false;
}
current = boardPoints[currentIndex];
return true;
}
}
// A range of q/r board coordinate values.
@immutable
class _Range {
const _Range(this.min, this.max)
: assert(min != null),
assert(max != null),
assert(min <= max);
final int min;
final int max;
}
final Set<Color> boardPointColors = <Color>{
Colors.grey,
Colors.black,
Colors.red,
Colors.blue,
};
// A location on the board in axial coordinates.
// Axial coordinates use two integers, q and r, to locate a hexagon on a grid.
// https://www.redblobgames.com/grids/hexagons/#coordinates-axial
@immutable
class BoardPoint {
const BoardPoint(this.q, this.r, {
this.color = Colors.grey,
});
final int q;
final int r;
final Color color;
@override
String toString() {
return 'BoardPoint($q, $r, $color)';
}
// Only compares by location.
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) {
return false;
}
final BoardPoint boardPoint = other;
return boardPoint.q == q && boardPoint.r == r;
}
@override
int get hashCode => hashValues(q, r);
BoardPoint copyWithColor(Color nextColor) => BoardPoint(q, r, color: nextColor);
// Convert from q,r axial coords to x,y,z cube coords.
Vector3 get cubeCoordinates {
return Vector3(
q.toDouble(),
r.toDouble(),
(-q - r).toDouble(),
);
}
}
import 'package:flutter/material.dart';
// A generic widget for a list of selectable colors.
@immutable
class ColorPicker extends StatelessWidget {
const ColorPicker({
@required this.colors,
@required this.selectedColor,
this.onColorSelection,
}) : assert(colors != null),
assert(selectedColor != null);
final Set<Color> colors;
final Color selectedColor;
final ValueChanged<Color> onColorSelection;
@override
Widget build (BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: colors.map((Color color) {
return _ColorPickerSwatch(
color: color,
selected: color == selectedColor,
onTap: () {
if (onColorSelection != null) {
onColorSelection(color);
}
},
);
}).toList(),
);
}
}
// A single selectable color widget in the ColorPicker.
@immutable
class _ColorPickerSwatch extends StatelessWidget {
const _ColorPickerSwatch({
@required this.color,
@required this.selected,
this.onTap,
}) : assert(color != null),
assert(selected != null);
final Color color;
final bool selected;
final Function onTap;
@override
Widget build (BuildContext context) {
return Container(
width: 60.0,
height: 60.0,
padding: const EdgeInsets.fromLTRB(2.0, 0.0, 2.0, 0.0),
child: RawMaterialButton(
fillColor: color,
onPressed: () {
if (onTap != null) {
onTap();
}
},
child: !selected ? null : const Icon(
Icons.check,
color: Colors.white,
),
),
);
}
}
import 'package:flutter/material.dart';
import 'transformations_demo_board.dart';
import 'transformations_demo_color_picker.dart';
// The panel for editing a board point.
@immutable
class EditBoardPoint extends StatelessWidget {
const EditBoardPoint({
Key key,
@required this.boardPoint,
this.onColorSelection,
}) : assert(boardPoint != null),
super(key: key);
final BoardPoint boardPoint;
final ValueChanged<Color> onColorSelection;
@override
Widget build (BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'${boardPoint.q}, ${boardPoint.r}',
textAlign: TextAlign.right,
style: const TextStyle(fontWeight: FontWeight.bold),
),
ColorPicker(
colors: boardPointColors,
selectedColor: boardPoint.color,
onColorSelection: onColorSelection,
),
],
);
}
}
import 'dart:math';
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
// the equation of motion from physics.
// https://en.wikipedia.org/wiki/Equations_of_motion#Constant_translational_acceleration_in_a_straight_line
// TODO(justinmc): Can this be replaced with friction_simulation.dart?
@immutable
class InertialMotion {
const InertialMotion(this._initialVelocity, this._initialPosition);
static const double _kFrictionalAcceleration = 0.01; // How quickly to stop
final Velocity _initialVelocity;
final Offset _initialPosition;
// The position when the motion stops.
Offset get finalPosition {
return _getPositionAt(Duration(milliseconds: duration.toInt()));
}
// The total time that the animation takes start to stop in milliseconds.
double get duration {
return (_initialVelocity.pixelsPerSecond.dx / 1000 / _acceleration.x).abs();
}
// The acceleration opposing the initial velocity in x and y components.
Vector2 get _acceleration {
// TODO(justinmc): Find actual velocity instead of summing?
final double velocityTotal = _initialVelocity.pixelsPerSecond.dx.abs()
+ _initialVelocity.pixelsPerSecond.dy.abs();
final double vRatioX = _initialVelocity.pixelsPerSecond.dx / velocityTotal;
final double vRatioY = _initialVelocity.pixelsPerSecond.dy / velocityTotal;
return Vector2(
_kFrictionalAcceleration * vRatioX,
_kFrictionalAcceleration * vRatioY,
);
}
// The position at a given time.
Offset _getPositionAt(Duration time) {
final double xf = _getPosition(
r0: _initialPosition.dx,
v0: _initialVelocity.pixelsPerSecond.dx / 1000,
t: time.inMilliseconds,
a: _acceleration.x,
);
final double yf = _getPosition(
r0: _initialPosition.dy,
v0: _initialVelocity.pixelsPerSecond.dy / 1000,
t: time.inMilliseconds,
a: _acceleration.y,
);
return Offset(xf, yf);
}
// Solve the equation of motion to find the position at a given point in time
// in one dimension.
double _getPosition({double r0, double v0, int t, double a}) {
// Stop movement when it would otherwise reverse direction.
final double stopTime = (v0 / a).abs();
if (t > stopTime) {
t = stopTime.toInt();
}
return r0 + v0 * t + 0.5 * a * pow(t, 2);
}
}
......@@ -123,6 +123,14 @@ List<GalleryDemo> _buildGalleryDemos() {
routeName: AnimationDemo.routeName,
buildRoute: (BuildContext context) => const AnimationDemo(),
),
GalleryDemo(
title: '2D Transformations',
subtitle: 'Pan, Zoom, Rotate',
icon: GalleryIcons.grid_on,
category: _kDemos,
routeName: TransformationsDemo.routeName,
buildRoute: (BuildContext context) => const TransformationsDemo(),
),
// Style
GalleryDemo(
......
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