| OLD | NEW |
| (Empty) |
| 1 // Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file | |
| 2 // for details. All rights reserved. Use of this source code is governed by a | |
| 3 // BSD-style license that can be found in the LICENSE file. | |
| 4 | |
| 5 /** | |
| 6 * Implementations can be used to simulate the deceleration of an element within | |
| 7 * a certain region. To use this behavior you need to provide an initial | |
| 8 * velocity that is meant to represent the gesture that is initiating this | |
| 9 * deceleration. You also provide the bounds of the region that the element | |
| 10 * exists in, and the current offset of the element within that region. The | |
| 11 * transitions will have the element decelerate to rest, or stretch past the | |
| 12 * offset boundaries and then come to rest. | |
| 13 * | |
| 14 * This is primarily designed to solve the problem of slow scrolling in mobile | |
| 15 * safari. You can use this along with the [Scroller] behavior to make a | |
| 16 * scrollable area scroll the same way it would in a native application. | |
| 17 * | |
| 18 * Implementations of this interface do not maintain any references to HTML | |
| 19 * elements, and therefore cannot do any redrawing of elements. They only | |
| 20 * calculates where the element should be on an interval. It is the delegate's | |
| 21 * responsibility to redraw the element when the onDecelerate callback is | |
| 22 * invoked. It is recommended that you move the element with a hardware | |
| 23 * accelerated method such as using 'translate3d' on the element's | |
| 24 * -webkit-transform style property. | |
| 25 */ | |
| 26 interface Momentum default TimeoutMomentum { | |
| 27 | |
| 28 Momentum(MomentumDelegate delegate, [num defaultDecelerationFactor]); | |
| 29 | |
| 30 bool get decelerating(); | |
| 31 | |
| 32 num get decelerationFactor(); | |
| 33 | |
| 34 /** | |
| 35 * Transition end handler. This function must be invoked after any transition | |
| 36 * that occurred as a result of a call to the delegate's onDecelerate callback. | |
| 37 */ | |
| 38 void onTransitionEnd(); | |
| 39 | |
| 40 /** | |
| 41 * Start decelerating. | |
| 42 * The [velocity] passed should be in terms of number of pixels / millisecond. | |
| 43 * [minCoord] and [maxCoord] specify the content's scrollable boundary. | |
| 44 * The current offset of the element within its boundaries is specified by | |
| 45 * [initialOffset]. | |
| 46 * Returns true if deceleration has been initiated. | |
| 47 */ | |
| 48 bool start(Coordinate velocity, Coordinate minCoord, Coordinate maxCoord, | |
| 49 Coordinate initialOffset, [num decelerationFactor]); | |
| 50 | |
| 51 /** | |
| 52 * Calculate the velocity required to transition between coordinates [start] | |
| 53 * and [target] optionally specifying a custom [decelerationFactor]. | |
| 54 */ | |
| 55 Coordinate calculateVelocity(Coordinate start, Coordinate target, | |
| 56 [num decelerationFactor]); | |
| 57 | |
| 58 /** Stop decelerating and return the current velocity. */ | |
| 59 Coordinate stop(); | |
| 60 | |
| 61 /** Aborts decelerating without dispatching any notification events. */ | |
| 62 void abort(); | |
| 63 | |
| 64 /** null if no transition is in progress. */ | |
| 65 Coordinate get destination(); | |
| 66 } | |
| 67 | |
| 68 /** | |
| 69 * Momentum Delegate interface. | |
| 70 * You are required to implement this interface in order to use the | |
| 71 * Momentum behavior. | |
| 72 */ | |
| 73 interface MomentumDelegate { | |
| 74 /** | |
| 75 * Callback for a deceleration step. The delegate is responsible for redrawing | |
| 76 * the element in its new position specified in px. | |
| 77 */ | |
| 78 void onDecelerate(num x, num y); | |
| 79 | |
| 80 /** | |
| 81 * Callback for end of deceleration. | |
| 82 */ | |
| 83 void onDecelerationEnd(); | |
| 84 } | |
| 85 | |
| 86 class BouncingState { | |
| 87 static final NOT_BOUNCING = 0; | |
| 88 static final BOUNCING_AWAY = 1; | |
| 89 static final BOUNCING_BACK = 2; | |
| 90 } | |
| 91 | |
| 92 class _Move { | |
| 93 final num x; | |
| 94 final num y; | |
| 95 final num vx; | |
| 96 final num vy; | |
| 97 final num time; | |
| 98 | |
| 99 _Move(this.x, this.y, this.vx, this.vy, this.time); | |
| 100 } | |
| 101 | |
| 102 /** | |
| 103 * Secant method root solver helper class. | |
| 104 * We use http://en.wikipedia.org/wiki/Secant_method | |
| 105 * falling back to the http://en.wikipedia.org/wiki/Bisection_method | |
| 106 * if it doesn't appear we are converging properlty. | |
| 107 * TODO(jacobr): simplify the code so we don't have to use this solver | |
| 108 * class at all. | |
| 109 */ | |
| 110 class Solver { | |
| 111 | |
| 112 static num solve(num fn(num), num targetY, num startX, | |
| 113 [int maxIterations = 50]) { | |
| 114 num lastX = 0; | |
| 115 num lastY = fn(lastX); | |
| 116 num deltaX; | |
| 117 num deltaY; | |
| 118 num minX = null; | |
| 119 num maxX = null; | |
| 120 num x = startX; | |
| 121 num delta = startX; | |
| 122 for (int i = 0; i < maxIterations; i++) { | |
| 123 num y = fn(x); | |
| 124 if (y.round() == targetY.round()) { | |
| 125 return x; | |
| 126 } | |
| 127 if (y > targetY) { | |
| 128 maxX = x; | |
| 129 } else { | |
| 130 minX = x; | |
| 131 } | |
| 132 | |
| 133 num errorY = targetY - y; | |
| 134 deltaX = x - lastX; | |
| 135 deltaY = y - lastY; | |
| 136 lastX = x; | |
| 137 lastY = y; | |
| 138 // Avoid divide by zero and as a hack just repeat the previous delta. | |
| 139 // Obviously this is a little dangerous and we might not converge. | |
| 140 if (deltaY != 0) { | |
| 141 delta = errorY * deltaX / deltaY; | |
| 142 } | |
| 143 x += delta; | |
| 144 if (minX != null && maxX != null | |
| 145 && (x > minX || x < maxX)) { | |
| 146 // Fall back to binary search. | |
| 147 x = (minX + maxX) / 2; | |
| 148 } | |
| 149 } | |
| 150 window.console.warn('''Could not find an exact solution. LastY=${lastY}, | |
| 151 targetY=${targetY} lastX=$lastX delta=$delta deltaX=$deltaX | |
| 152 deltaY=$deltaY'''); | |
| 153 return x; | |
| 154 } | |
| 155 } | |
| 156 | |
| 157 /** | |
| 158 * Helper class modeling the physics of a throwable scrollable area along a | |
| 159 * single dimension. | |
| 160 */ | |
| 161 class SingleDimensionPhysics { | |
| 162 /** The number of frames per second the animation should run at. */ | |
| 163 static final _FRAMES_PER_SECOND = 60; | |
| 164 | |
| 165 /** | |
| 166 * The spring coefficient for when the element has passed a boundary and is | |
| 167 * decelerating to change direction and bounce back. Each frame, the velocity | |
| 168 * will be changed by x times this coefficient, where x is the current stretch | |
| 169 * value of the element from its boundary. This will end when velocity reaches | |
| 170 * zero. | |
| 171 */ | |
| 172 static final _PRE_BOUNCE_COEFFICIENT = 7.0 / _FRAMES_PER_SECOND; | |
| 173 | |
| 174 /** | |
| 175 * The spring coefficient for when the element is bouncing back from a | |
| 176 * stretched offset to a min or max position. Each frame, the velocity will | |
| 177 * be changed to x times this coefficient, where x is the current stretch | |
| 178 * value of the element from its boundary. This will end when the stretch | |
| 179 * value reaches 0. | |
| 180 */ | |
| 181 static final _POST_BOUNCE_COEFFICIENT = 7.0 / _FRAMES_PER_SECOND; | |
| 182 | |
| 183 /** | |
| 184 * The number of milliseconds per animation frame. | |
| 185 */ | |
| 186 static final _MS_PER_FRAME = 1000.0 / _FRAMES_PER_SECOND; | |
| 187 | |
| 188 /** | |
| 189 * The constant factor applied to velocity at each frame to simulate | |
| 190 * deceleration. | |
| 191 */ | |
| 192 static final _DECELERATION_FACTOR = 0.97; | |
| 193 | |
| 194 | |
| 195 static final _MAX_VELOCITY_STATIC_FRICTION = 0.08 * _MS_PER_FRAME; | |
| 196 static final _DECELERATION_FACTOR_STATIC_FRICTION = 0.92; | |
| 197 | |
| 198 /** | |
| 199 * Minimum velocity required to start or continue deceleration, in | |
| 200 * pixels/frame. This is equivalent to 0.25 px/ms. | |
| 201 */ | |
| 202 static final _MIN_VELOCITY = 0.25 * _MS_PER_FRAME; | |
| 203 | |
| 204 /** | |
| 205 * Minimum velocity during a step, in pixels/frame. This is equivalent to 0.01 | |
| 206 * px/ms. | |
| 207 */ | |
| 208 static final _MIN_STEP_VELOCITY = 0.01 * _MS_PER_FRAME; | |
| 209 | |
| 210 /** | |
| 211 * Boost the initial velocity by a certain factor before applying momentum. | |
| 212 * This just gives the momentum a better feel. | |
| 213 */ | |
| 214 static final _INITIAL_VELOCITY_BOOST_FACTOR = 1.25; | |
| 215 | |
| 216 /** | |
| 217 * Additional deceleration factor to apply for the current move only. This | |
| 218 * is helpful for cases such as scroll wheel scrolling where the default | |
| 219 * amount of deceleration is inadequate. | |
| 220 */ | |
| 221 num customDecelerationFactor = 1; | |
| 222 num _minCoord; | |
| 223 num _maxCoord; | |
| 224 | |
| 225 /** The bouncing state. */ | |
| 226 int _bouncingState; | |
| 227 | |
| 228 | |
| 229 num velocity; | |
| 230 num _currentOffset; | |
| 231 | |
| 232 /** | |
| 233 * constant used when guessing at the velocity required to throw to a specific | |
| 234 * location. Chosen arbitrarily. All that really matters is that the velocity | |
| 235 * is large enough that a throw gesture will occur. | |
| 236 */ | |
| 237 static final _VELOCITY_GUESS = 20; | |
| 238 | |
| 239 SingleDimensionPhysics() : _bouncingState = BouncingState.NOT_BOUNCING { | |
| 240 } | |
| 241 | |
| 242 void configure(num minCoord, num maxCoord, | |
| 243 num initialOffset, num customDecelerationFactor_, | |
| 244 num velocity_) { | |
| 245 _bouncingState = BouncingState.NOT_BOUNCING; | |
| 246 _minCoord = minCoord; | |
| 247 _maxCoord = maxCoord; | |
| 248 _currentOffset = initialOffset; | |
| 249 this.customDecelerationFactor = customDecelerationFactor_; | |
| 250 _adjustInitialVelocityAndBouncingState(velocity_); | |
| 251 } | |
| 252 | |
| 253 num solve(num initialOffset, num targetOffset, | |
| 254 num customDecelerationFactor_) { | |
| 255 initialOffset = initialOffset.round(); | |
| 256 targetOffset = targetOffset.round(); | |
| 257 if (initialOffset == targetOffset) { | |
| 258 return 0; | |
| 259 } | |
| 260 return Solver.solve((num velocity_) { | |
| 261 // Don't specify min and max coordinates as we don't need to bother | |
| 262 // with the simulating bouncing off the edges. | |
| 263 configure(null, null, initialOffset.round(), | |
| 264 customDecelerationFactor_, velocity_); | |
| 265 stepAll(); | |
| 266 return _currentOffset; | |
| 267 }, | |
| 268 targetOffset, | |
| 269 targetOffset > initialOffset ? _VELOCITY_GUESS : -_VELOCITY_GUESS); | |
| 270 } | |
| 271 | |
| 272 /** | |
| 273 * Helper method to calculate initial velocity. | |
| 274 * The [velocity] passed here should be in terms of number of | |
| 275 * pixels / millisecond. Returns the adjusted x and y velocities. | |
| 276 */ | |
| 277 void _adjustInitialVelocityAndBouncingState(num v) { | |
| 278 velocity = v * _MS_PER_FRAME * _INITIAL_VELOCITY_BOOST_FACTOR; | |
| 279 | |
| 280 if (velocity.abs() < _MIN_VELOCITY) { | |
| 281 if (_minCoord !== null && _currentOffset < _minCoord) { | |
| 282 velocity = (_minCoord - _currentOffset) * _POST_BOUNCE_COEFFICIENT; | |
| 283 velocity = Math.max(velocity, _MIN_STEP_VELOCITY); | |
| 284 _bouncingState = BouncingState.BOUNCING_BACK; | |
| 285 } else if (_maxCoord !== null && _currentOffset > _maxCoord) { | |
| 286 velocity = (_currentOffset - _maxCoord) * _POST_BOUNCE_COEFFICIENT; | |
| 287 velocity = -Math.max(velocity, _MIN_STEP_VELOCITY); | |
| 288 _bouncingState = BouncingState.BOUNCING_BACK; | |
| 289 } | |
| 290 } | |
| 291 } | |
| 292 | |
| 293 /** | |
| 294 * Apply deceleration. | |
| 295 */ | |
| 296 void _adjustVelocity() { | |
| 297 num speed = velocity.abs(); | |
| 298 velocity *= _DECELERATION_FACTOR; | |
| 299 if (customDecelerationFactor != null) { | |
| 300 velocity *= customDecelerationFactor; | |
| 301 } | |
| 302 // This isn't really how static friction works but it is a plausible | |
| 303 // approximation. | |
| 304 if (speed < _MAX_VELOCITY_STATIC_FRICTION) { | |
| 305 velocity *= _DECELERATION_FACTOR_STATIC_FRICTION; | |
| 306 } | |
| 307 | |
| 308 num stretchDistance; | |
| 309 if (_minCoord !== null && _currentOffset < _minCoord) { | |
| 310 stretchDistance = _minCoord - _currentOffset; | |
| 311 } else { | |
| 312 if (_maxCoord !== null && _currentOffset > _maxCoord) { | |
| 313 stretchDistance = _maxCoord - _currentOffset; | |
| 314 } | |
| 315 } | |
| 316 if (stretchDistance != null) { | |
| 317 if (stretchDistance * velocity < 0) { | |
| 318 _bouncingState = _bouncingState == BouncingState.BOUNCING_BACK ? | |
| 319 BouncingState.NOT_BOUNCING : BouncingState.BOUNCING_AWAY; | |
| 320 velocity += stretchDistance * _PRE_BOUNCE_COEFFICIENT; | |
| 321 } else { | |
| 322 _bouncingState = BouncingState.BOUNCING_BACK; | |
| 323 velocity = stretchDistance > 0 ? | |
| 324 Math.max(stretchDistance * _POST_BOUNCE_COEFFICIENT, | |
| 325 _MIN_STEP_VELOCITY) : | |
| 326 Math.min(stretchDistance * _POST_BOUNCE_COEFFICIENT, | |
| 327 -_MIN_STEP_VELOCITY); | |
| 328 } | |
| 329 } else { | |
| 330 _bouncingState = BouncingState.NOT_BOUNCING; | |
| 331 } | |
| 332 } | |
| 333 | |
| 334 void step() { | |
| 335 // It is common for scrolling to be disabled so in these cases we want to | |
| 336 // avoid needless calculations. | |
| 337 if (velocity !== null) { | |
| 338 _currentOffset += velocity; | |
| 339 _adjustVelocity(); | |
| 340 } | |
| 341 } | |
| 342 | |
| 343 void stepAll() { | |
| 344 while(!isDone()) { | |
| 345 step(); | |
| 346 } | |
| 347 } | |
| 348 | |
| 349 /** | |
| 350 * Whether or not the current velocity is above the threshold required to | |
| 351 * continue decelerating. | |
| 352 */ | |
| 353 bool isVelocityAboveThreshold(num threshold) { | |
| 354 return velocity.abs() >= threshold; | |
| 355 } | |
| 356 | |
| 357 bool isDone() { | |
| 358 return _bouncingState == BouncingState.NOT_BOUNCING && | |
| 359 !isVelocityAboveThreshold(_MIN_STEP_VELOCITY); | |
| 360 } | |
| 361 } | |
| 362 | |
| 363 /** | |
| 364 * Implementation of a momentum strategy using webkit-transforms | |
| 365 * and timeouts. | |
| 366 */ | |
| 367 class TimeoutMomentum implements Momentum { | |
| 368 | |
| 369 SingleDimensionPhysics physicsX; | |
| 370 SingleDimensionPhysics physicsY; | |
| 371 Coordinate _previousOffset; | |
| 372 Queue<_Move> _moves; | |
| 373 num _stepTimeout; | |
| 374 bool _decelerating; | |
| 375 MomentumDelegate _delegate; | |
| 376 int _nextY; | |
| 377 int _nextX; | |
| 378 Coordinate _minCoord; | |
| 379 Coordinate _maxCoord; | |
| 380 num _customDecelerationFactor; | |
| 381 num _defaultDecelerationFactor; | |
| 382 | |
| 383 TimeoutMomentum(this._delegate, [num defaultDecelerationFactor = 1]) | |
| 384 : _defaultDecelerationFactor = defaultDecelerationFactor, | |
| 385 _decelerating = false, | |
| 386 _moves = new Queue<_Move>(), | |
| 387 physicsX = new SingleDimensionPhysics(), | |
| 388 physicsY = new SingleDimensionPhysics(); | |
| 389 | |
| 390 /** | |
| 391 * Calculate and return the moves for the deceleration motion. | |
| 392 */ | |
| 393 void _calculateMoves() { | |
| 394 _moves.clear(); | |
| 395 num time = TimeUtil.now(); | |
| 396 while (!physicsX.isDone() || !physicsY.isDone()) { | |
| 397 _stepWithoutAnimation(); | |
| 398 time += SingleDimensionPhysics._MS_PER_FRAME; | |
| 399 if (_isStepNecessary()) { | |
| 400 _moves.add(new _Move(_nextX, _nextY, | |
| 401 physicsX.velocity, | |
| 402 physicsY.velocity, time)); | |
| 403 _previousOffset.y = _nextY; | |
| 404 _previousOffset.x = _nextX; | |
| 405 } | |
| 406 } | |
| 407 } | |
| 408 | |
| 409 bool get decelerating() => _decelerating; | |
| 410 num get decelerationFactor() => _customDecelerationFactor; | |
| 411 | |
| 412 /** | |
| 413 * Checks whether or not an animation step is necessary or not. Animations | |
| 414 * steps are not necessary when the velocity gets so low that in several | |
| 415 * frames the offset is the same. | |
| 416 * Returns true if there is movement to be done in the next frame. | |
| 417 */ | |
| 418 bool _isStepNecessary() { | |
| 419 return _nextY != _previousOffset.y || _nextX != _previousOffset.x; | |
| 420 } | |
| 421 | |
| 422 /** | |
| 423 * The [TouchHandler] requires this function but we don't need to do | |
| 424 * anything here. | |
| 425 */ | |
| 426 void onTransitionEnd() { | |
| 427 } | |
| 428 | |
| 429 Coordinate calculateVelocity(Coordinate start_, Coordinate target, | |
| 430 [num decelerationFactor = null]) { | |
| 431 return new Coordinate( | |
| 432 physicsX.solve(start_.x, target.x, decelerationFactor), | |
| 433 physicsY.solve(start_.y, target.y, decelerationFactor)); | |
| 434 } | |
| 435 | |
| 436 bool start(Coordinate velocity, Coordinate minCoord, Coordinate maxCoord, | |
| 437 Coordinate initialOffset, [num decelerationFactor = null]) { | |
| 438 _customDecelerationFactor = _defaultDecelerationFactor; | |
| 439 if (decelerationFactor !== null) { | |
| 440 _customDecelerationFactor = decelerationFactor; | |
| 441 } | |
| 442 | |
| 443 if (_stepTimeout !== null) { | |
| 444 Env.cancelRequestAnimationFrame(_stepTimeout); | |
| 445 _stepTimeout = null; | |
| 446 } | |
| 447 | |
| 448 assert (_stepTimeout === null); | |
| 449 assert(minCoord.x <= maxCoord.x); | |
| 450 assert(minCoord.y <= maxCoord.y); | |
| 451 _previousOffset = initialOffset.clone(); | |
| 452 physicsX.configure(minCoord.x, maxCoord.x, initialOffset.x, | |
| 453 _customDecelerationFactor, velocity.x); | |
| 454 physicsY.configure(minCoord.y, maxCoord.y, initialOffset.y, | |
| 455 _customDecelerationFactor, velocity.y); | |
| 456 if (!physicsX.isDone() || !physicsY.isDone()) { | |
| 457 _calculateMoves(); | |
| 458 if (!_moves.isEmpty()) { | |
| 459 num firstTime = _moves.first().time; | |
| 460 _stepTimeout = Env.requestAnimationFrame( | |
| 461 _step, null, firstTime); | |
| 462 _decelerating = true; | |
| 463 return true; | |
| 464 } | |
| 465 } | |
| 466 _decelerating = false; | |
| 467 return false; | |
| 468 } | |
| 469 | |
| 470 /** | |
| 471 * Update the x, y values of the element offset without actually moving the | |
| 472 * element. This is done because we store decimal values for x, y for | |
| 473 * precision, but moving is only required when the offset is changed by at | |
| 474 * least a whole integer. | |
| 475 */ | |
| 476 void _stepWithoutAnimation() { | |
| 477 physicsX.step(); | |
| 478 physicsY.step(); | |
| 479 // TODO(jacobr): double.round() should return an int, see b/5121907 | |
| 480 _nextX = physicsX._currentOffset.round().toInt(); | |
| 481 _nextY = physicsY._currentOffset.round().toInt(); | |
| 482 } | |
| 483 | |
| 484 /** | |
| 485 * Calculate the next offset of the element and animate it to that position. | |
| 486 */ | |
| 487 void _step(num timestamp) { | |
| 488 _stepTimeout = null; | |
| 489 | |
| 490 // Prune moves that are more than 1 frame behind when we have more | |
| 491 // available moves. | |
| 492 num lastEpoch = timestamp - SingleDimensionPhysics._MS_PER_FRAME; | |
| 493 while (!_moves.isEmpty() && _moves.first() !== _moves.last() | |
| 494 && _moves.first().time < lastEpoch) { | |
| 495 _moves.removeFirst(); | |
| 496 } | |
| 497 | |
| 498 if (!_moves.isEmpty()) { | |
| 499 final move = _moves.removeFirst(); | |
| 500 _delegate.onDecelerate(move.x, move.y); | |
| 501 if (!_moves.isEmpty()) { | |
| 502 num nextTime = _moves.first().time; | |
| 503 assert(_stepTimeout === null); | |
| 504 _stepTimeout = Env.requestAnimationFrame(_step, null, nextTime); | |
| 505 } else { | |
| 506 stop(); | |
| 507 } | |
| 508 } | |
| 509 } | |
| 510 | |
| 511 void abort() { | |
| 512 _decelerating = false; | |
| 513 _moves.clear(); | |
| 514 if (_stepTimeout !== null) { | |
| 515 Env.cancelRequestAnimationFrame(_stepTimeout); | |
| 516 _stepTimeout = null; | |
| 517 } | |
| 518 } | |
| 519 | |
| 520 Coordinate stop() { | |
| 521 final wasDecelerating = _decelerating; | |
| 522 _decelerating = false; | |
| 523 Coordinate velocity; | |
| 524 if (!_moves.isEmpty()) { | |
| 525 final move = _moves.first(); | |
| 526 // This is a workaround for the ugly hacks that get applied when a user | |
| 527 // passed a velocity in to this Momentum implementation. | |
| 528 num velocityScale = SingleDimensionPhysics._MS_PER_FRAME * | |
| 529 SingleDimensionPhysics._INITIAL_VELOCITY_BOOST_FACTOR; | |
| 530 velocity = new Coordinate( | |
| 531 move.vx / velocityScale, move.vy / velocityScale); | |
| 532 } else { | |
| 533 velocity = new Coordinate(0, 0); | |
| 534 } | |
| 535 _moves.clear(); | |
| 536 if (_stepTimeout !== null) { | |
| 537 Env.cancelRequestAnimationFrame(_stepTimeout); | |
| 538 _stepTimeout = null; | |
| 539 } | |
| 540 if (wasDecelerating) { | |
| 541 _delegate.onDecelerationEnd(); | |
| 542 } | |
| 543 return velocity; | |
| 544 } | |
| 545 | |
| 546 Coordinate get destination() { | |
| 547 if (!_moves.isEmpty()) { | |
| 548 final lastMove = _moves.last(); | |
| 549 return new Coordinate(lastMove.x, lastMove.y); | |
| 550 } else { | |
| 551 return null; | |
| 552 } | |
| 553 } | |
| 554 } | |
| OLD | NEW |