All Downloads are FREE. Search and download functionalities are using the official Maven repository.

tripleplay.ui.Scroller Maven / Gradle / Ivy

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