Lemur ScrollPanel based on ListBox

I’ve just done a ScrollPanel (basically copy/pasting ListBox’s code and removing all the unnecessary… more or less xD).

By the moment just added the possibility to set the scrollbar position and if this must be hidden if there is no enough content to it to be useful.

Well, the code is:

import com.google.common.base.Objects;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import com.simsilica.lemur.*;
import com.simsilica.lemur.component.BorderLayout;
import com.simsilica.lemur.component.QuadBackgroundComponent;
import com.simsilica.lemur.core.AbstractGuiControlListener;
import com.simsilica.lemur.core.GuiControl;
import com.simsilica.lemur.core.VersionedList;
import com.simsilica.lemur.core.VersionedReference;
import com.simsilica.lemur.grid.GridModel;
import com.simsilica.lemur.list.CellRenderer;
import com.simsilica.lemur.list.DefaultCellRenderer;
import com.simsilica.lemur.style.*;

import java.util.List;

public class ScrollPanel<T> extends Panel {

    public static final String ELEMENT_ID = "list";
    public static final String CONTAINER_ID = "container";
    public static final String ITEMS_ID = "items";
    public static final String SLIDER_ID = "slider";
//    public static final String SELECTOR_ID = "selector";

    private BorderLayout layout;
    private VersionedList<T> model;
    private VersionedReference<List<T>> modelRef;
    private CellRenderer<T> cellRenderer;

//    private ScrollPanel.ClickListener clickListener = new ScrollPanel.ClickListener();

    private CustomGridPanel grid;
    private Slider slider;
    private Node selectorArea;
//    private Panel selector;
    private Vector3f selectorAreaOrigin = new Vector3f();
    private Vector3f selectorAreaSize = new Vector3f();
    private RangedValueModel baseIndex;  // upside down actually
    private VersionedReference<Double> indexRef;
    private int maxIndex;

    private BorderLayout.Position sliderPosition = BorderLayout.Position.East;
    private boolean sliderAlwaysVisible = false;

    public ScrollPanel() {
        this(true, new VersionedList<T>(), null,
                new ElementId(ELEMENT_ID), null);
    }

    public ScrollPanel( VersionedList<T> model ) {
        this(true, model, null,
                new ElementId(ELEMENT_ID), null);
    }

    public ScrollPanel( VersionedList<T> model, CellRenderer<T> renderer, String style ) {
        this(true, model, renderer, new ElementId(ELEMENT_ID), style);
    }

    public ScrollPanel( VersionedList<T> model, String style ) {
        this(true, model, null, new ElementId(ELEMENT_ID), style);
    }

    public ScrollPanel( VersionedList<T> model, ElementId elementId, String style ) {
        this(true, model, null, elementId, style);
    }

    public ScrollPanel( VersionedList<T> model, CellRenderer<T> renderer, ElementId elementId, String style ) {
        this(true, model, renderer, elementId, style);
    }

    protected ScrollPanel( boolean applyStyles, VersionedList<T> model, CellRenderer<T> cellRenderer,
                       ElementId elementId, String style ) {
        super(false, elementId.child(CONTAINER_ID), style);

        if( cellRenderer == null ) {
            // Create a default one
            cellRenderer = new DefaultCellRenderer(elementId.child("item"), style);
        }
        this.cellRenderer = cellRenderer;

        this.layout = new BorderLayout();
        getControl(GuiControl.class).setLayout(layout);

        grid = new CustomGridPanel(new ScrollPanel.GridModelDelegate(), elementId.child(ITEMS_ID), style);
        grid.setVisibleColumns(1);
        grid.getControl(GuiControl.class).addListener(new ScrollPanel.GridListener());
        layout.addChild(grid, BorderLayout.Position.Center);

        baseIndex = new DefaultRangedValueModel();
        indexRef = baseIndex.createReference();

        slider = new Slider(baseIndex, Axis.Y, elementId.child(SLIDER_ID), style);

        if(sliderAlwaysVisible) {
            layout.addChild(slider, sliderPosition);
        }

        if( applyStyles ) {
            Styles styles = GuiGlobals.getInstance().getStyles();
            styles.applyStyles(this, getElementId(), style);
        }

        // Need a spacer so that the 'selector' panel doesn't think
        // it's being managed by this panel.
        // Have to set this up after applying styles so that the default
        // styles are properly initialized the first time.
        selectorArea = new Node("selectorArea");
        attachChild(selectorArea);
//        selector = new Panel(elementId.child(SELECTOR_ID), style);

        setModel(model);
        resetModelRange();
    }

    @StyleDefaults(ELEMENT_ID)
    public static void initializeDefaultStyles( Styles styles, Attributes attrs ) {

        ElementId parent = new ElementId(ELEMENT_ID);
        //QuadBackgroundComponent quad = new QuadBackgroundComponent(new ColorRGBA(0.5f, 0.5f, 0.5f, 1));
        QuadBackgroundComponent quad = new QuadBackgroundComponent(new ColorRGBA(0.8f, 0.9f, 0.1f, 1));
        quad.getMaterial().getMaterial().getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Exclusion);
//        styles.getSelector(parent.child(SELECTOR_ID), null).set("background", quad, false);
    }

    @Override
    public void updateLogicalState( float tpf ) {
        super.updateLogicalState(tpf);

        if( modelRef.update() ) {
            resetModelRange();
        }

        boolean indexUpdate = indexRef.update();
        if( indexUpdate ) {
            int index = (int)(maxIndex - baseIndex.getValue());
            grid.setRow(index);
        }
    }

    protected void gridResized( Vector3f pos, Vector3f size ) {
        if( pos.equals(selectorAreaOrigin) && size.equals(selectorAreaSize) ) {
            return;
        }

        selectorAreaOrigin.set(pos);
        selectorAreaSize.set(size);
    }

    public void setModel( VersionedList<T> model ) {
        if( this.model == model && model != null ) {
            return;
        }

        if( this.model != null ) {
            // Clean up the old one
//            detachItemListeners();
        }

        if( model == null ) {
            // Easier to create a default one than to handle a null model
            // everywhere
            model = new VersionedList<T>();
        }

        this.model = model;
        this.modelRef = model.createReference();

        grid.setLocation(0,0);
        grid.setModel(new ScrollPanel.GridModelDelegate());  // need a new one for a new version
        resetModelRange();
        baseIndex.setValue(maxIndex);
    }

    public void scrollToBottom() {
        baseIndex.setValue(0);
    }

    public boolean isSliderAlwaysVisible() {
        return sliderAlwaysVisible;
    }

    public void setSliderAlwaysVisible(boolean sliderAlwaysVisible) {
        this.sliderAlwaysVisible = sliderAlwaysVisible;
    }

    public BorderLayout.Position getSliderPosition() {
        return sliderPosition;
    }

    public void setSliderPosition(BorderLayout.Position sliderPosition) {
        this.sliderPosition = sliderPosition;
    }

    public VersionedList<T> getModel() {
        return model;
    }

    public Slider getSlider() {
        return slider;
    }

    public GridPanel getGridPanel() {
        return grid;
    }

    @StyleAttribute(value="visibleItems", lookupDefault=false)
    public void setVisibleItems( int count ) {
        grid.setVisibleRows(count);
        resetModelRange();
    }

    public int getVisibleItems() {
        return grid.getVisibleRows();
    }

    @StyleAttribute(value="cellRenderer", lookupDefault=false)
    public void setCellRenderer( CellRenderer renderer ) {
        if( Objects.equal(this.cellRenderer, renderer) ) {
            return;
        }
        this.cellRenderer = renderer;
        grid.refreshGrid(); // cheating
    }

    public CellRenderer getCellRenderer() {
        return cellRenderer;
    }

    protected void resetModelRange() {
        int count = model == null ? 0 : model.size();
        int visible = grid.getVisibleRows();
        maxIndex = Math.max(0, count - visible);

        // Because the slider is upside down, we have to
        // do some math if we want our base not to move as
        // items are added to the list after us
        double val = baseIndex.getMaximum() - baseIndex.getValue();

        baseIndex.setMinimum(0);
        baseIndex.setMaximum(maxIndex);
        baseIndex.setValue(maxIndex - val);

        // Hide scrollbar
        if(!sliderAlwaysVisible) {
            if (count > visible) {
                if (slider.getParent() == null) {
                    layout.addChild(slider, sliderPosition);
                }
            } else {
                if (slider.getParent() != null) {
                    layout.removeChild(slider);
                }
            }
        }
    }

    protected Panel getListCell( int row, int col, Panel existing ) {
        T value = model.get(row);
        Panel cell = cellRenderer.getView(value, false, existing);

        if( cell != existing ) {

        }
        return cell;
    }

    @Override
    public String toString() {
        return getClass().getName() + "[elementId=" + getElementId() + "]";
    }

    private class GridListener extends AbstractGuiControlListener {
        public void reshape( GuiControl source, Vector3f pos, Vector3f size ) {
            gridResized(pos, size);

            // If the grid was re-laid out then we probably need
            // to refresh our selector
//            refreshSelector();
        }
    }

    protected class GridModelDelegate implements GridModel<Panel> {

        @Override
        public int getRowCount() {
            if( model == null ) {
                return 0;
            }
            return model.size();
        }

        @Override
        public int getColumnCount() {
            return 1;
        }

        @Override
        public Panel getCell( int row, int col, Panel existing ) {
            return getListCell(row, col, existing);
        }

        @Override
        public void setCell( int row, int col, Panel value ) {
            throw new UnsupportedOperationException("ListModel is read only.");
        }

        @Override
        public long getVersion() {
            return model == null ? 0 : model.getVersion();
        }

        @Override
        public GridModel<Panel> getObject() {
            return this;
        }

        @Override
        public VersionedReference<GridModel<Panel>> createReference() {
            return new VersionedReference<GridModel<Panel>>(this);
        }
    }

}
1 Like

Cool. I’m not really clear on what this is meant to do. Can you expand on your description?

Well, it works just like the ListBox but without highlighting any element or allowing to click them. It’s like a textarea with a scrollpanel but instead of passing to it a single element/string it’s needed to pass the different lines (It’s really just the listbox but without highlighting and clicking).

I use it to wrap a whole text without worrying if this overflows. First I use a method to split the text based on the panel width so I get a list of lines (which I use as the panel model). I can post the wrapping/text splitting code too but it can be further more optimized (anyway, it supports too long words, which separate with a “-”).

Cool, I get it now.

That part made it crystal clear. :slight_smile:

I’m trying to make a scrollable container for a menu myself, and wanted to try your code myself to see if it was anywhere near what I wanted. What is CustomGridPanel and do you have a copy of it @NemesisMate ?

Short of that I think I might just have to “roll my own” and do some shader trickery or something.

Here you can find it:

It is just an extend of the Lemur’s GridPanel to make a needed method public.

This, however, doesn’t have smooth scroll (for that a viewport is needed). So, if a smooth behavior is needed I suggest you to try the ViewportPanel2D mentioned here. (It hasn’t a scrollbar but adding a scrollbar shouldn’t be too hard.

This is my effort so far. It’s based more on a game-type menu that scrolls the selected item to the top row. So when you are using a gamepad for example, the menu scrolls from a single place. It works, it’s fine, but it isn’t the best option for anything more than a menu…

1 Like

do you have code for this?

Sure. It’s actually just a mouse listener that you attach to a container.

You use it in the following manner:

// getContainer() is the container you want to use the scroller on...
// startPosition is the Y value of the container position
// menuItems is a list of buttons or whatever (List<Panel>) used to determine sizes for scrolling.
// I just added each item that I added to the container in the list. So every time i added a button
// (or whatever) to the container, I also added it to the list.
// getAnimationState() is a reference to lemur's animation appstate for animating the movement.

VerticalScrollMouseListener listener = new VerticalScrollMouseListener(getContainer(), startPosition, menuItems, getAnimationState());
getContainer().addMouseListener(listener);
package com.jayfella.emu.menu;

import com.jme3.input.event.MouseButtonEvent;
import com.jme3.input.event.MouseMotionEvent;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
import com.simsilica.lemur.Panel;
import com.simsilica.lemur.anim.AnimationState;
import com.simsilica.lemur.anim.SpatialTweens;
import com.simsilica.lemur.anim.Tween;
import com.simsilica.lemur.event.MouseListener;

import java.util.List;

/**
 * Created by James on 21/02/2017.
 */
public class VerticalScrollMouseListener implements MouseListener {

    private final Panel parent;
    private final float startPosition;
    private final List<Panel> menuItems;
    private final AnimationState animationState;

    private final int deltaPerStep = 120; // a delta of +/- 120 is one step of the mouse wheel.
    private int selectedIndex = 0; // the current index of the menu

    /**
     * Creates a listener that allows a menu to scroll vertically.
     * @param parent         The container of the menu (which will be moved by the scroll-wheel)
     * @param startPosition  The vertical start position of the container - used as a reference for the offset.
     * @param menuItems      The items in the menu.
     * @param animationState The lemur animation state used to animate from one position to the next.
     */
    public VerticalScrollMouseListener(Panel parent, float startPosition, List<Panel> menuItems, AnimationState animationState) {
        this.parent = parent;
        this.startPosition = startPosition;
        this.menuItems = menuItems;
        this.animationState = animationState;
    }

    @Override
    public void mouseMoved(MouseMotionEvent event, Spatial target, Spatial capture) {

        if (event.getDeltaWheel() != 0) {

            int indexChange = event.getDeltaWheel() / deltaPerStep;

            // positive is away from the user, negative is toward.
            // the delta will determine the "index"
            // System.out.println(index);

            int newIndexChange = 0;

            // when the user scrolls really fast they can scroll many items at once.
            // we need to ensure they aren't scrolling past the ends of the menu
            // so we'll iterate over the change amount and break if a limit is exceeded.
            if (indexChange > 0) {

                for (int i = 1; i <= indexChange; i++) {

                    if ((selectedIndex + i) >= 1) {
                        break;
                    }
                    else {
                        newIndexChange += 1;
                    }
                }
            }
            else {

                for (int i = -1; i >= indexChange; i--) {

                    if ((selectedIndex + i) <= -menuItems.size()) {
                        break;
                    }
                    else {
                        newIndexChange -= 1;
                    }
                }
            }

            // we've reduced the index change to it's maximum allowable change based on
            // how many menu items there are, so let's continue use indexChange instead of newIndexChange.
            indexChange = newIndexChange;
            selectedIndex += indexChange;

            // if we calculate the end position based on how many items we moved compared to its current location,
            // the animation gets confused over fast movements. So instead we will calculate its end position
            // based on where it should be - a.k.a its pre-determined position.
            float newPosY = 0;

            // the index is flipped, because scrolling down becomes negative.
            // therefore, zero is top, and -menuItems.size() is bottom.
            int absIndex = Math.abs(selectedIndex);

            // to move things properly, we need to know the size of the object.
            // we start from zero to its current index to determine where the menu should be positioned.
            for (int i = 0; i < absIndex; i++) {
                newPosY += menuItems.get(i).getPreferredSize().y;
            }

            // add start position
            newPosY += startPosition;

            Vector3f currentPos = parent.getLocalTranslation();
            Vector3f newPos = new Vector3f(currentPos.x, newPosY, currentPos.z);

            Tween move = SpatialTweens.move(parent, currentPos, newPos, .1f);
            animationState.add(move);

        }

    }

    @Override
    public void mouseButtonEvent(MouseButtonEvent event, Spatial target, Spatial capture) {

    }

    @Override
    public void mouseEntered(MouseMotionEvent event, Spatial target, Spatial capture) {

    }

    @Override
    public void mouseExited(MouseMotionEvent event, Spatial target, Spatial capture) {

    }


}

2 Likes