Просмотр исходного кода

Grid: touch kinetic scrolling. #18133 #16885

This is a new implementation of scrolling for grid we
have in v-grid.
It uses GWT animations instead of having to deal with
the HTML5 animation framework directly and implements
the concepts of velocity and acceleration.

Change-Id: I11cb0098e8a0e61b989edd7a383a069fa064ab32
tags/7.6.0.alpha5
Manolo Carrasco 9 лет назад
Родитель
Сommit
0922cc0ee6
1 измененных файлов: 147 добавлений и 312 удалений
  1. 147
    312
      client/src/com/vaadin/client/widgets/Escalator.java

+ 147
- 312
client/src/com/vaadin/client/widgets/Escalator.java Просмотреть файл

@@ -29,11 +29,13 @@ import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.google.gwt.animation.client.Animation;
import com.google.gwt.animation.client.AnimationScheduler;
import com.google.gwt.animation.client.AnimationScheduler.AnimationCallback;
import com.google.gwt.animation.client.AnimationScheduler.AnimationHandle;
import com.google.gwt.core.client.Duration;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.dom.client.DivElement;
@@ -48,6 +50,7 @@ import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.dom.client.TableCellElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.TableSectionElement;
import com.google.gwt.dom.client.Touch;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.logging.client.LogConfiguration;
import com.google.gwt.user.client.Command;
@@ -72,6 +75,7 @@ import com.vaadin.client.widget.escalator.PositionFunction.AbsolutePosition;
import com.vaadin.client.widget.escalator.PositionFunction.Translate3DPosition;
import com.vaadin.client.widget.escalator.PositionFunction.TranslatePosition;
import com.vaadin.client.widget.escalator.PositionFunction.WebkitTranslate3DPosition;
import com.vaadin.client.widget.escalator.Row;
import com.vaadin.client.widget.escalator.RowContainer;
import com.vaadin.client.widget.escalator.RowContainer.BodyRowContainer;
import com.vaadin.client.widget.escalator.RowVisibilityChangeEvent;
@@ -302,8 +306,6 @@ public class Escalator extends Widget implements RequiresResize,
static class JsniUtil {
public static class TouchHandlerBundle {

private static final double FLICK_POLL_FREQUENCY = 100d;

/**
* A <a href=
* "http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsOverlay.html"
@@ -338,105 +340,8 @@ public class Escalator extends Widget implements RequiresResize,
}-*/;
}

private double touches = 0;
private int lastX = 0;
private int lastY = 0;
private boolean snappedScrollEnabled = true;
private double deltaX = 0;
private double deltaY = 0;

private final Escalator escalator;

private CustomTouchEvent latestTouchMoveEvent;

/** The timestamp of {@link #flickPageX1} and {@link #flickPageY1} */
private double flickStartTime = 0;

/** The timestamp of {@link #flickPageX2} and {@link #flickPageY2} */
private double flickTimestamp = 0;

/** The most recent flick touch reference Y */
private double flickPageY1 = -1;
/** The most recent flick touch reference X */
private double flickPageX1 = -1;

/** The previous flick touch reference Y, before {@link #flickPageY1} */
private double flickPageY2 = -1;
/** The previous flick touch reference X, before {@link #flickPageX1} */
private double flickPageX2 = -1;

/**
* This animation callback guarantees the fact that we don't scroll
* the grid more than once per visible frame.
*
* It seems that there will never be more touch events than there
* are rendered frames, but there's no guarantee for that. If it was
* guaranteed, we probably could do all of this immediately in
* {@link #touchMove(CustomTouchEvent)}, instead of deferring it
* over here.
*/
private AnimationCallback mover = new AnimationCallback() {

@Override
public void execute(double timestamp) {
if (touches != 1) {
return;
}

final int x = latestTouchMoveEvent.getPageX();
final int y = latestTouchMoveEvent.getPageY();

/*
* Check if we need a new flick coordinate sample ( more
* than FLICK_POLL_FREQUENCY ms have passed since the last
* sample )
*/
if (System.currentTimeMillis() - flickTimestamp > FLICK_POLL_FREQUENCY) {

flickTimestamp = System.currentTimeMillis();
// Set target coordinates
flickPageY2 = y;
flickPageX2 = x;
}

deltaX = x - lastX;
deltaY = y - lastY;
lastX = x;
lastY = y;

// snap the scroll to the major axes, at first.
if (snappedScrollEnabled) {
final double oldDeltaX = deltaX;
final double oldDeltaY = deltaY;

/*
* Scrolling snaps to 40 degrees vs. flick scroll's 30
* degrees, since slow movements have poor resolution -
* it's easy to interpret a slight angle as a steep
* angle, since the sample rate is "unnecessarily" high.
* 40 simply felt better than 30.
*/
final double[] snapped = Escalator.snapDeltas(deltaX,
deltaY, RATIO_OF_40_DEGREES);
deltaX = snapped[0];
deltaY = snapped[1];

/*
* if the snap failed once, let's follow the pointer
* from now on.
*/
if (oldDeltaX != 0 && deltaX == oldDeltaX
&& oldDeltaY != 0 && deltaY == oldDeltaY) {
snappedScrollEnabled = false;
}
}

moveScrollFromEvent(escalator, -deltaX, -deltaY,
latestTouchMoveEvent.getNativeEvent());
}
};
private AnimationHandle animationHandle;

public TouchHandlerBundle(final Escalator escalator) {
this.escalator = escalator;
}
@@ -468,79 +373,157 @@ public class Escalator extends Widget implements RequiresResize,
});
}-*/;

public void touchStart(final CustomTouchEvent event) {
touches = event.getNativeEvent().getTouches().length();
if (touches != 1) {
return;
// Duration of the inertial scrolling simulation. Devices with
// larger screens take longer durations.
private static final int DURATION = (int)Window.getClientHeight();
// multiply scroll velocity with repeated touching
private int acceleration = 1;
private boolean touching = false;
// Two movement objects for storing status and processing touches
private Movement yMov, xMov;
final double MIN_VEL = 0.6, MAX_VEL = 4, F_VEL = 1500, F_ACC = 0.7, F_AXIS = 1;

// The object to deal with one direction scrolling
private class Movement {
final List<Double> speeds = new ArrayList<Double>();
final ScrollbarBundle scroll;
double position, offset, velocity, prevPos, prevTime, delta;
boolean run, vertical;

public Movement(boolean vertical) {
this.vertical = vertical;
scroll = vertical ? escalator.verticalScrollbar : escalator.horizontalScrollbar;
}

escalator.scroller.cancelFlickScroll();
public void startTouch(CustomTouchEvent event) {
speeds.clear();
prevPos = pagePosition(event);
prevTime = Duration.currentTimeMillis();
}
public void moveTouch(CustomTouchEvent event) {
double pagePosition = pagePosition(event);
if (pagePosition > -1) {
delta = prevPos - pagePosition;
double now = Duration.currentTimeMillis();
double ellapsed = now - prevTime;
velocity = delta / ellapsed;
// if last speed was so low, reset speeds and start storing again
if (speeds.size() > 0 && !validSpeed(speeds.get(0))) {
speeds.clear();
run = true;
}
speeds.add(0, velocity);
prevTime = now;
prevPos = pagePosition;
}
}
public void endTouch(CustomTouchEvent event) {
// Compute average speed
velocity = 0;
for (double s : speeds) {
velocity += s / speeds.size();
}
position = scroll.getScrollPos();
// Compute offset, and adjust it with an easing curve so as movement is smoother.
offset = F_VEL * velocity * acceleration * easingInOutCos(velocity, MAX_VEL);
// Check that offset does not over-scroll
double minOff = -scroll.getScrollPos();
double maxOff = scroll.getScrollSize() - scroll.getOffsetSize() + minOff;
offset = Math.min(Math.max(offset, minOff), maxOff);
// Enable or disable inertia movement in this axis
run = validSpeed(velocity) && minOff < 0 && maxOff > 0;
if (run) {
event.getNativeEvent().preventDefault();
}
}
void validate(Movement other) {
if (!run || other.velocity > 0 && Math.abs(velocity / other.velocity) < F_AXIS) {
delta = offset = 0;
run = false;
}
}
void stepAnimation(double progress) {
scroll.setScrollPos(position + offset * progress);
}

lastX = event.getPageX();
lastY = event.getPageY();
int pagePosition(CustomTouchEvent event) {
JsArray<Touch> a = event.getNativeEvent().getTouches();
return vertical ? a.get(0).getPageY() : a.get(0).getPageX();
}
boolean validSpeed(double speed) {
return Math.abs(speed) > MIN_VEL;
}
}

// Reset flick parameters
flickPageX1 = lastX;
flickPageX2 = -1;
flickPageY1 = lastY;
flickPageY2 = -1;
flickStartTime = System.currentTimeMillis();
flickTimestamp = 0;
// Using GWT animations which take care of native animation frames.
private Animation animation = new Animation() {
public void onUpdate(double progress) {
xMov.stepAnimation(progress);
yMov.stepAnimation(progress);
}
public double interpolate(double progress) {
return easingOutCirc(progress);
};
public void onComplete() {
touching = false;
escalator.body.domSorter.reschedule();
};
public void run(int duration) {
if (xMov.run || yMov.run) {
super.run(duration);
} else {
onComplete();
}
};
};

snappedScrollEnabled = true;
public void touchStart(final CustomTouchEvent event) {
if (event.getNativeEvent().getTouches().length() == 1) {
if (yMov == null) {
yMov = new Movement(true);
xMov = new Movement(false);
}
if (animation.isRunning()) {
acceleration += F_ACC;
event.getNativeEvent().preventDefault();
animation.cancel();
} else {
acceleration = 1;
}
xMov.startTouch(event);
yMov.startTouch(event);
touching = true;
}
}

public void touchMove(final CustomTouchEvent event) {
/*
* since we only use the getPageX/Y, and calculate the diff
* within the handler, we don't need to calculate any
* intermediate deltas.
*/
latestTouchMoveEvent = event;

if (animationHandle != null) {
animationHandle.cancel();
}
animationHandle = AnimationScheduler.get()
.requestAnimationFrame(mover, escalator.bodyElem);
event.getNativeEvent().preventDefault();
xMov.moveTouch(event);
yMov.moveTouch(event);
xMov.validate(yMov);
yMov.validate(xMov);
moveScrollFromEvent(escalator, xMov.delta, yMov.delta, event.getNativeEvent());
}

public void touchEnd(final CustomTouchEvent event) {
touches = event.getNativeEvent().getTouches().length();

if (touches == 0) {

/*
* We want to smooth the flick calculations here. We have
* taken a frame of reference every FLICK_POLL_FREQUENCY.
* But if the sample is too fresh, we might introduce noise
* in our sampling, so we use the older sample instead. it
* might be less accurate, but it's smoother.
*
* flickPage?1 is the most recent one, while flickPage?2 is
* the previous one.
*/

final double finalPageY;
final double finalPageX;
double deltaT = flickTimestamp - flickStartTime;
boolean onlyOneSample = flickPageX2 < 0 || flickPageY2 < 0;
if (onlyOneSample) {
finalPageX = latestTouchMoveEvent.getPageX();
finalPageY = latestTouchMoveEvent.getPageY();
} else {
finalPageY = flickPageY2;
finalPageX = flickPageX2;
}

double deltaX = finalPageX - flickPageX1;
double deltaY = finalPageY - flickPageY1;
xMov.endTouch(event);
yMov.endTouch(event);
xMov.validate(yMov);
yMov.validate(xMov);
// Adjust duration so as longer movements take more duration
boolean vert = !xMov.run || yMov.run && Math.abs(yMov.offset) > Math.abs(xMov.offset);
double delta = Math.abs((vert ? yMov : xMov).offset);
animation.run((int)(3 * DURATION * easingOutExp(delta)));
}

escalator.scroller
.handleFlickScroll(deltaX, deltaY, deltaT);
escalator.body.domSorter.reschedule();
}
private double easingInOutCos(double val, double max) {
return 0.5 - 0.5 * Math.cos(Math.PI * Math.signum(val)
* Math.min(Math.abs(val), max) / max);
}
private double easingOutExp(double delta) {
return (1 - Math.pow(2, -delta / 1000));
}
private double easingOutCirc(double progress) {
return Math.sqrt(1 - (progress - 1) * (progress - 1));
}
}

@@ -570,117 +553,6 @@ public class Escalator extends Widget implements RequiresResize,
}
}

/**
* The animation callback that handles the animation of a touch-scrolling
* flick with inertia.
*/
private class FlickScrollAnimator implements AnimationCallback {
private static final double MIN_MAGNITUDE = 0.005;
private static final double MAX_SPEED = 7;

private double velX;
private double velY;
private double prevTime = 0;
private int millisLeft;
private double xFric;
private double yFric;

private boolean cancelled = false;
private double lastLeft;
private double lastTop;

/**
* Creates a new animation callback to handle touch-scrolling flick with
* inertia.
*
* @param deltaX
* the last scrolling delta in the x-axis in a touchmove
* @param deltaY
* the last scrolling delta in the y-axis in a touchmove
* @param lastTime
* the timestamp of the last touchmove
*/
public FlickScrollAnimator(final double deltaX, final double deltaY,
final double deltaT) {
velX = Math.max(Math.min(deltaX / deltaT, MAX_SPEED), -MAX_SPEED);
velY = Math.max(Math.min(deltaY / deltaT, MAX_SPEED), -MAX_SPEED);

lastLeft = horizontalScrollbar.getScrollPos();
lastTop = verticalScrollbar.getScrollPos();

/*
* If we're scrolling mainly in one of the four major directions,
* and only a teeny bit to any other side, snap the scroll to that
* major direction instead.
*/
final double[] snapDeltas = Escalator.snapDeltas(velX, velY,
RATIO_OF_30_DEGREES);
velX = snapDeltas[0];
velY = snapDeltas[1];

if (velX * velX + velY * velY > MIN_MAGNITUDE) {
millisLeft = 1500;
xFric = velX / millisLeft;
yFric = velY / millisLeft;
} else {
millisLeft = 0;
}

}

@Override
public void execute(final double doNotUseThisTimestamp) {
/*
* We cannot use the timestamp provided to this method since it is
* of a format that cannot be determined at will. Therefore, we need
* a timestamp format that we can handle, so our calculations are
* correct.
*/

if (millisLeft <= 0 || cancelled) {
scroller.currentFlickScroller = null;
return;
}

final double timestamp = Duration.currentTimeMillis();
if (prevTime == 0) {
prevTime = timestamp;
AnimationScheduler.get().requestAnimationFrame(this);
return;
}

double currentLeft = horizontalScrollbar.getScrollPos();
double currentTop = verticalScrollbar.getScrollPos();

final double timeDiff = timestamp - prevTime;
double left = currentLeft - velX * timeDiff;
setScrollLeft(left);
velX -= xFric * timeDiff;

double top = currentTop - velY * timeDiff;
setScrollTop(top);
velY -= yFric * timeDiff;

cancelBecauseOfEdgeOrCornerMaybe();

prevTime = timestamp;
millisLeft -= timeDiff;
lastLeft = currentLeft;
lastTop = currentTop;
AnimationScheduler.get().requestAnimationFrame(this);
}

private void cancelBecauseOfEdgeOrCornerMaybe() {
if (lastLeft == horizontalScrollbar.getScrollPos()
&& lastTop == verticalScrollbar.getScrollPos()) {
cancel();
}
}

public void cancel() {
cancelled = true;
}
}

/**
* ScrollDestination case-specific handling logic.
@@ -760,11 +632,6 @@ public class Escalator extends Widget implements RequiresResize,
private class Scroller extends JsniWorkaround {
private double lastScrollTop = 0;
private double lastScrollLeft = 0;
/**
* The current flick scroll animator. This is <code>null</code> if the
* view isn't animating a flick scroll at the moment.
*/
private FlickScrollAnimator currentFlickScroller;

public Scroller() {
super(Escalator.this);
@@ -782,7 +649,7 @@ public class Escalator extends Widget implements RequiresResize,

return $entry(function(e) {
var target = e.target || e.srcElement; // IE8 uses e.scrElement
// in case the scroll event was native (i.e. scrollbars were dragged, or
// the scrollTop/Left was manually modified), the bundles have old cache
// values. We need to make sure that the caches are kept up to date.
@@ -1100,30 +967,6 @@ public class Escalator extends Widget implements RequiresResize,
}
}-*/;

private void cancelFlickScroll() {
if (currentFlickScroller != null) {
currentFlickScroller.cancel();
}
}

/**
* Handles a touch-based flick scroll.
*
* @param deltaX
* the last scrolling delta in the x-axis in a touchmove
* @param deltaY
* the last scrolling delta in the y-axis in a touchmove
* @param lastTime
* the timestamp of the last touchmove
*/
public void handleFlickScroll(double deltaX, double deltaY,
double deltaT) {
currentFlickScroller = new FlickScrollAnimator(deltaX, deltaY,
deltaT);
AnimationScheduler.get()
.requestAnimationFrame(currentFlickScroller);
}

public void scrollToColumn(final int columnIndex,
final ScrollDestination destination, final int padding) {
assert columnIndex >= columnConfiguration.frozenColumns : "Can't scroll to a frozen column";
@@ -2481,9 +2324,9 @@ public class Escalator extends Widget implements RequiresResize,
private boolean sortIfConditionsMet() {
boolean enoughFramesHavePassed = framesPassed >= REQUIRED_FRAMES_PASSED;
boolean enoughTimeHasPassed = (Duration.currentTimeMillis() - startTime) >= SORT_DELAY_MILLIS;
boolean notAnimatingFlick = (scroller.currentFlickScroller == null);
boolean notTouchActivity = !scroller.touchHandlerBundle.touching;
boolean conditionsMet = enoughFramesHavePassed
&& enoughTimeHasPassed && notAnimatingFlick;
&& enoughTimeHasPassed && notTouchActivity;

if (conditionsMet) {
resetConditions();
@@ -2663,15 +2506,7 @@ public class Escalator extends Widget implements RequiresResize,

if (rowsWereMoved) {
fireRowVisibilityChangeEvent();

if (scroller.touchHandlerBundle.touches == 0) {
/*
* this will never be called on touch scrolling. That is
* handled separately and explicitly by
* TouchHandlerBundle.touchEnd();
*/
domSorter.reschedule();
}
domSorter.reschedule();
}
}


Загрузка…
Отмена
Сохранить