
tripleplay.ui.Scroller Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of tripleplay Show documentation
Show all versions of tripleplay Show documentation
Utilities for use in PlayN-based games.
The newest version!
//
// Triple Play - utilities for use in PlayN-based games
// Copyright (c) 2011-2018, Triple Play Authors - All rights reserved.
// http://github.com/threerings/tripleplay/blob/master/LICENSE
package tripleplay.ui;
import java.util.ArrayList;
import java.util.List;
import playn.core.Scale;
import pythagoras.f.Dimension;
import pythagoras.f.IDimension;
import pythagoras.f.IPoint;
import pythagoras.f.Point;
import react.Closeable;
import react.Connection;
import react.Signal;
import react.Slot;
import react.UnitSlot;
import playn.core.Clock;
import playn.core.Color;
import playn.core.Surface;
import playn.scene.GroupLayer;
import playn.scene.Interaction;
import playn.scene.Layer;
import playn.scene.Mouse;
import playn.scene.Pointer;
import tripleplay.ui.layout.AxisLayout;
import tripleplay.ui.util.XYFlicker;
import tripleplay.util.Colors;
import tripleplay.util.Layers;
/**
* A composite element that manages horizontal and vertical scrolling of a single content element.
* As shown below, the content can be thought of as moving around behind the scroll group, which is
* clipped to create a "view" to the content. Methods {@link #xpos} and {@link #ypos} allow reading
* the current position of the view. The view position can be set with {@link #scroll}. The view
* size and content size are available via {@link #viewSize} and {@link #contentSize}.
*
* {@code
* Scrolled view (xpos,ypos>0) View unscrolled (xpos,ypos=0)
* --------------------------- ---------------------------
* | : | | Scroll | |
* | content : ypos | | Group | |
* | : | | "view" | |
* | ----------- | |---------- |
* | | Scroll | | | |
* |---xpos--->| Group | | | |
* | | "view" | | | content |
* | ----------- | | |
* --------------------------- ---------------------------
* }
*
* Scroll bars are configurable via the {@link #BAR_TYPE} style.
*
*
NOTE: {@code Scroller} is a composite container, so callers can't add to or remove from it.
* To "add" elements, callers should set {@link #content} to a {@code Group} and add things to it
* instead.
*
*
NOTE: scrolling is done by pointer events; there are two ways to provide interactive
* (clickable) content.
*
*
- The first way is to pass {@code bubble=true} to {@link Pointer.Dispatcher}'s
* constructor. This allows any descendants within the content to be clicked normally. With this
* approach, after the pointer has been dragged more than a minimum distance, the {@code Scroller}
* calls {@link Interaction#capture}, which will cancel all other pointer interactions, including
* clickable descendants. For buttons or toggles, this causes the element to be deselected,
* corresponding to popular mobile OS conventions.
*
* - The second way is to use the {@link #contentClicked} signal. This is more lightweight but
* only emits after the pointer is released less than a minimum distance away from its starting
* position.
*
* TODO: some way to handle keyboard events (complicated by lack of a focus element)
* TODO: more fine-grained setPropagateEvents (add a flag to playn Layer?)
* TODO: temporarily allow drags past the min/max scroll positions and bounce back
*/
public class Scroller extends Composite
{
/** The type of bars to use. By default, uses an instance of {@link TouchBars}. */
public static final Style BAR_TYPE = Style.newStyle(true, new BarType() {
@Override public Bars createBars (Scroller scroller) {
return new TouchBars(scroller, Color.withAlpha(Colors.BLACK, 128), 5f, 3f, 1.5f / 1000);
}
});
/** The buffer around a child element when updating visibility ({@link #updateVisibility()}.
* The default value (0x0) causes any elements whose exact bounds lie outside the clipped
* area to be culled. If elements are liable to have overhanging layers, the value can be set
* larger appropriately. */
public static final Style ELEMENT_BUFFER =
Style.newStyle(true, new Dimension(0, 0));
/**
* Interface for customizing how content is clipped and translated.
* @see Scroller#Scroller
*/
public interface Clippable {
/**
* Sets the size of the area the content should clip to. In the default clipping, this
* has no effect (it relies solely on the clipped group surrounding the content).
* This will always be called prior to {@code setPosition}.
*/
void setViewArea (float width, float height);
/**
* Sets the translation of the content, based on scroll bar positions. Both numbers will
* be non-positive, up to the maximum position of the content such that its right or
* bottom edge aligns with the width or height of the view area, respectively. For the
* default clipping, this just sets the translation of the content's layer.
*/
void setPosition (float x, float y);
}
/**
* Handles creating the scroll bars.
*/
public static abstract class BarType {
/**
* Creates the scroll bars.
*/
public abstract Bars createBars (Scroller scroller);
}
/**
* Listens for changes to the scrolling area or offset.
*/
public interface Listener {
/**
* Notifies this listener of changes to the content size or scroll size. Normally this
* happens when either the content or scroll group is validated.
* @param contentSize the new size of the content
* @param scrollSize the new size of the viewable area
*/
void viewChanged (IDimension contentSize, IDimension scrollSize);
/**
* Notifies this listener of changes to the content offset. Note the offset values are
* positive numbers, so correspond to the position of the view area over the content.
* @param xpos the horizontal amount by which the view is offset
* @param ypos the vertical amount by which the view is offset
*/
void positionChanged (float xpos, float ypos);
}
/**
* Defines the directions available for scrolling.
*/
public enum Behavior {
HORIZONTAL, VERTICAL, BOTH;
public boolean hasHorizontal () {
return this == HORIZONTAL || this == BOTH;
}
public boolean hasVertical () {
return this == VERTICAL || this == BOTH;
}
}
/**
* A range along an axis for representing scroll bars. Using the content and view extent,
* calculates the relative sizes.
*/
public static class Range {
/**
* Returns the maximum value that this range can have, in content offset coordinates.
*/
public float max () {
return _max;
}
/**
* Tests if the range is currently active. A range is inactive if it's turned off
* explicitly or if the view size is larger than the content size.
*/
public boolean active () {
return _max != 0;
}
/** Gets the size of the content along this range's axis. */
public float contentSize () {
return _on ? _csize : _size;
}
/** Gets the size of the view along this scroll bar's axis. */
public float viewSize () {
return _size;
}
/** Gets the current content offset. */
public float contentPos () {
return _cpos;
}
protected void setOn (boolean on) {
_on = on;
}
protected boolean on () {
return _on;
}
/** Set the view size and content size along this range's axis. */
protected float setRange (float viewSize, float contentSize) {
_size = viewSize;
_csize = contentSize;
if (!_on || _size >= _csize) {
// no need to render, clear fields
_max = _extent = _pos = _cpos = 0;
return 0;
} else {
// prepare rendering fields
_max = _csize - _size;
_extent = _size * _size / _csize;
_pos = Math.min(_pos, _size - _extent);
_cpos = _pos / (_size - _extent) * _max;
return _cpos;
}
}
/** Sets the position of the content along this range's axis. */
protected boolean set (float cpos) {
if (cpos == _cpos) return false;
_cpos = cpos;
_pos = _max == 0 ? 0 : cpos / _max * (_size - _extent);
return true;
}
/** During size computation, extends the provided hint. */
protected float extendHint (float hint) {
// we want the content to take up as much space as it wants if this bar is on
// TODO: use Float.MAX? that may cause trouble in other layout code
return _on ? 100000 : hint;
}
/** If this range is in use. Set according to {@link Scroller.Behavior}. */
protected boolean _on = true;
/** View size. */
protected float _size;
/** Content size. */
protected float _csize;
/** Bar offset. */
protected float _pos;
/** Content offset. */
protected float _cpos;
/** Thumb size. */
protected float _extent;
/** The maximum position the content can have. */
protected float _max;
}
/**
* Handles the appearance and animation of scroll bars.
*/
public static abstract class Bars implements Closeable
{
/**
* Updates the scroll bars to match the current view and content size. This will be
* called during layout, prior to the call to {@link #layer()}.
*/
public void updateView () {}
/**
* Gets the layer to display the scroll bars. It gets added to the same parent as the
* content's.
*/
public abstract Layer layer ();
/**
* Updates the scroll bars' time based animation, if any, after the given time delta.
*/
public void update (float dt) {}
/**
* Updates the scroll bars' positions. Not necessary for immediate layer bars.
*/
public void updatePosition () {}
/**
* Destroys the resources created by the bars.
*/
@Override public void close () {
layer().close();
}
/**
* Space consumed by active scroll bars.
*/
public float size () {
return 0;
}
/**
* Creates new bars for the given {@code Scroller}.
*/
protected Bars (Scroller scroller) {
_scroller = scroller;
}
protected final Scroller _scroller;
}
/**
* Plain rectangle scroll bars that overlay the content area, consume no additional screen
* space, and fade out after inactivity. Ideal for drag scrolling on a mobile device.
*/
public static class TouchBars extends Bars
{
public TouchBars (Scroller scroller, int color, float size,
float topAlpha, float fadeSpeed) {
super(scroller);
_color = color;
_size = size;
_topAlpha = topAlpha;
_fadeSpeed = fadeSpeed;
_layer = new Layer() {
@Override protected void paintImpl (Surface surface) {
surface.saveTx();
surface.setFillColor(_color);
Range h = _scroller.hrange, v = _scroller.vrange;
if (h.active()) drawBar(surface, h._pos, v._size - _size, h._extent, _size);
if (v.active()) drawBar(surface, h._size - _size, v._pos, _size, v._extent);
surface.restoreTx();
}
};
}
@Override public void update (float delta) {
// fade out the bars
if (_alpha > 0 && _fadeSpeed > 0) setBarAlpha(_alpha - _fadeSpeed * delta);
}
@Override public void updatePosition () {
// whenever the position changes, update to full visibility
setBarAlpha(_topAlpha);
}
@Override public Layer layer () {
return _layer;
}
protected void setBarAlpha (float alpha) {
_alpha = Math.min(_topAlpha, Math.max(0, alpha));
_layer.setAlpha(Math.min(_alpha, 1));
_layer.setVisible(_alpha > 0);
}
protected void drawBar (Surface surface, float x, float y, float w, float h) {
surface.fillRect(x, y, w, h);
}
protected float _alpha;
protected float _topAlpha;
protected float _fadeSpeed;
protected int _color;
protected float _size;
protected Layer _layer;
}
/**
* Finds the closest ancestor of the given element that is a {@code Scroller}, or null if
* there isn't one. This uses the tripleplay ui hierarchy.
*/
public static Scroller findScrollParent (Element> elem) {
for (; elem != null && !(elem instanceof Scroller); elem = elem.parent()) {}
return (Scroller)elem;
}
/**
* Attempts to scroll the given element into view.
* @return true if successful
*/
public static boolean makeVisible (final Element> elem) {
Scroller scroller = findScrollParent(elem);
if (scroller == null) return false;
// the element in question may have been added and then immediately scrolled to, which
// means it hasn't been laid out yet and does not have its proper position; in that case
// defer this process a tick to allow it to be laid out
if (!scroller.isSet(Flag.VALID)) {
elem.root().iface.frame.connect(new UnitSlot() {
@Override public void onEmit () {
makeVisible(elem);
}
}).once();
return true;
}
Point offset = Layers.transform(new Point(0, 0), elem.layer, scroller.content.layer);
scroller.scroll(offset.x, offset.y);
return true;
}
/** The content contained in the scroller. */
public final Element> content;
/** Scroll ranges. */
public final Range hrange = createRange(), vrange = createRange();
/** Handles the flicking physics. */
public final XYFlicker flicker = new XYFlicker(layer);
/**
* Creates a new scroller containing the given content and with {@link Scroller.Behavior#BOTH}.
* If the content is an instance of {@link Clippable}, then translation will occur via that
* interface. Otherwise, the content's layer translation will be set directly. Graphics level
* clipping is always performed.
*/
public Scroller (Element> content) {
setLayout(AxisLayout.horizontal().stretchByDefault().offStretch().gap(0));
// our only immediate child is the _scroller, and that contains the content
initChildren(_scroller = new Group(new ScrollLayout()) {
@Override protected GroupLayer createLayer () {
// use 1, 1 so we don't crash. the real size is set on validation
return new GroupLayer(1, 1);
}
@Override protected void layout () {
super.layout();
// do this after children have validated their bounding boxes
updateVisibility();
}
});
_scroller.add(this.content = content);
// use the content's clipping method if it is Clippable
if (content instanceof Clippable) {
_clippable = (Clippable)content;
} else {
// otherwise, clip using layer translation
_clippable = new Clippable() {
@Override public void setViewArea (float width, float height) { /* noop */ }
@Override public void setPosition (float x, float y) {
Root root = root();
if (root != null) {
Scale scale = root.iface.plat.graphics().scale();
if (hrange.active()) x = scale.roundToNearestPixel(x);
if (vrange.active()) y = scale.roundToNearestPixel(y);
}
Scroller.this.content.layer.setTranslation(x, y);
}
};
}
// absorb clicks so that pointer drag can always scroll
set(Flag.HIT_ABSORB, true);
// handle mouse wheel
layer.events().connect(new Mouse.Listener() {
@Override public void onWheel (Mouse.WheelEvent event, Mouse.Interaction iact) {
// scale so each wheel notch is 1/4 the screen dimension
float delta = event.velocity * .25f;
if (vrange.active()) scrollY(ypos() + (int)(delta * viewSize().height()));
else scrollX(xpos() + (int)(delta * viewSize().width()));
}
});
// handle drag scrolling
layer.events().connect(flicker);
}
/**
* Sets the behavior of this scroller.
*/
public Scroller setBehavior (Behavior beh) {
hrange.setOn(beh.hasHorizontal());
vrange.setOn(beh.hasVertical());
invalidate();
return this;
}
/**
* Adds a listener to be notified of this scroller's changes.
*/
public void addListener (Listener lner) {
if (_lners == null) _lners = new ArrayList();
_lners.add(lner);
}
/**
* Removes a previously added listener from this scroller.
*/
public void removeListener (Listener lner) {
if (_lners != null) _lners.remove(lner);
}
/**
* Returns the offset of the left edge of the view area relative to that of the content.
*/
public float xpos () {
return hrange._cpos;
}
/**
* Returns the offset of the top edge of the view area relative to that of the content.
*/
public float ypos () {
return vrange._cpos;
}
/**
* Sets the left edge of the view area relative to that of the content. The value is clipped
* to be within its valid range.
*/
public void scrollX (float x) {
scroll(x, ypos());
}
/**
* Sets the top edge of the view area relative to that of the content. The value is clipped
* to be within its valid range.
*/
public void scrollY (float y) {
scroll(xpos(), y);
}
/**
* Sets the left and top of the view area relative to that of the content. The values are
* clipped to be within their respective valid ranges.
*/
public void scroll (float x, float y) {
x = Math.max(0, Math.min(x, hrange._max));
y = Math.max(0, Math.min(y, vrange._max));
flicker.positionChanged(x, y);
}
/**
* Sets the left and top of the view area relative to that of the content the next time the
* container is laid out. This is needed if the caller invalidates the content and needs
* to then set a scroll position which may be out of range for the old size.
*/
public void queueScroll (float x, float y) {
_queuedScroll = new Point(x, y);
}
/**
* Gets the size of the content that we are responsible for scrolling. Scrolling is active for
* a given axis when this is larger than {@link #viewSize} along that axis.
*/
public IDimension contentSize () {
return _contentSize;
}
/**
* Gets the size of the view which renders some portion of the content.
*/
public IDimension viewSize () {
return _scroller.size();
}
/**
* Gets the signal dispatched when a pointer click occurs in the scroller. This happens
* only when the drag was not far enough to cause appreciable scrolling.
*/
public Signal contentClicked () {
return flicker.clicked;
}
/** Prepares the scroller for the next frame, at t = t + delta. */
protected void update (float delta) {
flicker.update(delta);
update(false);
if (_bars != null) _bars.update(delta);
}
/** Updates the position of the content to match the flicker. If force is set, then the
* relevant values will be updated even if there was no change. */
protected void update (boolean force) {
IPoint pos = flicker.position();
boolean dx = hrange.set(pos.x()), dy = vrange.set(pos.y());
if (dx || dy || force) {
_clippable.setPosition(-pos.x(), -pos.y());
// now check the child elements for visibility
if (!force) updateVisibility();
firePositionChange();
if (_bars != null) _bars.updatePosition();
}
}
/**
* A method for creating our {@code Range} instances. This is called once each for {@code
* hrange} and {@code vrange} at creation time. Overriding this method will allow subclasses
* to customize {@code Range} behavior.
*/
protected Range createRange () {
return new Range();
}
/** Extends the usual layout with scroll bar setup. */
protected class BarsLayoutData extends LayoutData {
public final BarType barType = resolveStyle(BAR_TYPE);
}
@Override protected LayoutData createLayoutData (float hintX, float hintY) {
return new BarsLayoutData();
}
@Override protected Class> getStyleClass () {
return Scroller.class;
}
@Override protected void wasAdded () {
super.wasAdded();
_upconn = root().iface.frame.connect(new Slot() {
public void onEmit (Clock clock) { update(clock.dt); }
});
invalidate();
}
@Override protected void wasRemoved () {
_upconn.close();
updateBars(null); // make sure bars get destroyed in case we don't get added again
super.wasRemoved();
}
/** Hides the layers of any children of the content that are currently visible but outside
* the clipping area. */
// TODO: can we get the performance win without being so intrusive?
protected void updateVisibility () {
// only Container can participate, others must implement Clippable and do something else
if (!(content instanceof Container)) {
return;
}
// hide the layer of any child of content that isn't in bounds
float x = hrange._cpos, y = vrange._cpos, wid = hrange._size, hei = vrange._size;
float bx = _elementBuffer.width(), by = _elementBuffer.height();
for (Element> child : (Container>)content) {
IDimension size = child.size();
if (child.isVisible()) child.layer.setVisible(
child.x() - bx < x + wid && child.x() + size.width() + bx > x &&
child.y() - by < y + hei && child.y() + size.height() + by > y);
}
}
/** Dispatches a {@link Listener#viewChanged()} to listeners. */
protected void fireViewChanged () {
if (_lners == null) return;
IDimension csize = contentSize(), ssize = viewSize();
for (Listener lner : _lners) {
lner.viewChanged(csize, ssize);
}
}
/** Dispatches a {@link Listener#positionChanged()} to listeners. */
protected void firePositionChange ()
{
if (_lners == null) return;
for (Listener lner : _lners) {
lner.positionChanged(xpos(), ypos());
}
}
protected void updateBars (BarType barType) {
if (_bars != null) {
if (_barType == barType) return;
_bars.close();
_bars = null;
}
_barType = barType;
if (_barType != null) _bars = _barType.createBars(this);
}
@Override protected void layout (LayoutData ldata, float left, float top,
float width, float height) {
// set the bars and element buffer first so the ScrollLayout can use them
_elementBuffer = resolveStyle(ELEMENT_BUFFER);
updateBars(((BarsLayoutData)ldata).barType);
super.layout(ldata, left, top, width, height);
if (_bars != null) layer.add(_bars.layer().setDepth(1).setTranslation(left, top));
}
/** Lays out the internal scroller group that contains the content. Performs all the jiggery
* pokery necessary to make the content think it is in a large area and update the outer
* {@code Scroller} instance. */
protected class ScrollLayout extends Layout
{
@Override public Dimension computeSize (Container> elems, float hintX, float hintY) {
// the content is always the 1st child, get the preferred size with extended hints
assert elems.childCount() == 1 && elems.childAt(0) == content;
_contentSize.setSize(preferredSize(elems.childAt(0),
hrange.extendHint(hintX), vrange.extendHint(hintY)));
return new Dimension(_contentSize);
}
@Override public void layout (Container> elems, float left, float top, float width,
float height) {
assert elems.childCount() == 1 && elems.childAt(0) == content;
// if we're going to have H or V scrolling, make room on the bottom and/or right
if (hrange.on() && _contentSize.width > width) height -= _bars.size();
if (vrange.on() && _contentSize.height > height) width -= _bars.size();
// reset ranges
left = hrange.setRange(width, _contentSize.width);
top = vrange.setRange(height, _contentSize.height);
// let the bars know about the range change
if (_bars != null) _bars.updateView();
// set the content bounds to the large virtual area starting at 0, 0
setBounds(content, 0, 0, hrange.contentSize(), vrange.contentSize());
// clip the content in its own special way
_clippable.setViewArea(width, height);
// clip the scroller layer too, can't hurt
_scroller.layer.setSize(width, height);
// reset the flicker (it retains its current position)
flicker.reset(hrange.max(), vrange.max());
// scroll the content
if (_queuedScroll != null) {
scroll(_queuedScroll.x, _queuedScroll.y);
_queuedScroll = null;
} else {
scroll(left, top);
}
// force an update so the scroll bars have properly aligned positions
update(true);
// notify listeners of a view change
fireViewChanged();
}
}
protected final Group _scroller;
protected final Clippable _clippable;
protected final Dimension _contentSize = new Dimension();
protected Connection _upconn;
protected Point _queuedScroll;
protected List _lners;
/** Scroll bar type, used to determine if the bars need to be recreated. */
protected BarType _barType;
/** Scroll bars, created during layout, based on the {@link BarType}. */
protected Bars _bars;
/** Region around elements when updating visibility. */
protected IDimension _elementBuffer;
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy