CustomList.java

/*
 * Copyright © 2012 Nokia Corporation. All rights reserved.
 * Nokia and Nokia Connecting People are registered trademarks of Nokia Corporation.
 * Oracle and Java are trademarks or registered trademarks of Oracle and/or its
 * affiliates. Other product and company names mentioned herein may be trademarks
 * or trade names of their respective owners.
 * See LICENSE.TXT for license information.
 */

package com.nokia.example.favouriteartists;

import java.util.Vector;

import javax.microedition.lcdui.*;

import com.nokia.example.favouriteartists.tool.Log;
import com.nokia.mid.ui.gestures.*;
import com.nokia.mid.ui.frameanimator.*;


/**
 * Simple list control that uses the Gestures- and Frame Animator APIs for scrolling
 * and handling selecting list items. Each list item has an image and a short
 * string.
 * <p>
 *  This class illustrates the use of Gestures API and Frame Animator API.
 * Points of interests are: the constructor {@link #CustomList(CustomListOwner, short, short)},
 * Gestures API callback {@link #gestureAction(Object, GestureInteractiveZone, GestureEvent)} and
 * Frame Animator API callback {@link #animate(FrameAnimator, int, int, short, short, short, boolean)}
 * <p>
 * Gestures used in this example:
 * {@link GestureInteractiveZone#GESTURE_TAP}
 * {@link GestureInteractiveZone#GESTURE_LONG_PRESS}
 * {@link GestureInteractiveZone#GESTURE_DRAG}
 * {@link GestureInteractiveZone#GESTURE_FLICK}
 * <p>
 * From Frame Animator API, {@link FrameAnimator#kineticScroll(int, int, int, float)} is used
 * with {@link FrameAnimator#FRAME_ANIMATOR_VERTICAL} to achieve kinetic scrolling of the list
 * (used for flick gesture).
 * <p>
 * Items can be selected by either short tap or long press. An action can be mapped for both gestures.
 * When an item is selected the selection is highlighted briefly to give the user some visual feedback
 * of the selection (See {@link #handleSelectEvent} and {@link CustomList.SelectDelay}).
 * <p>
 * The {@link ListItem} objects represent individual items and are responsible for drawing themselves.
 * <p>
 * Scrolling is done by modifying the {@link #translateY} value based on the
 * values received from the Gestures- and Frame Animator API callbacks. The list
 * paint always goes through all list items from first to last, but skips
 * drawing of items that aren't partially or fully visible. In theory the list
 * items can be of any height (in theory because this hasn't been tested).
 * <p>
 * This list control does not inherit any {@link Displayable} directly. Instead, the owner
 * instance is a {@link Displayable} that delegates actual painting etc. to this class.
 * The reason for this is to make this class re-usable ({@link Canvas} and {@link CustomItem})
 */
public class CustomList implements GestureListener, FrameAnimatorListener{

    // Member data
    /** Owner of this list control. */
    private CustomListOwner owner;
    /** Short tap action. */
    private short tapActionId;
    /** Long press action */
    private short longPressActionId;
    /** Height */
    private int height;
    /** The selected item, only valid during select action handling. */
    private ListItem selectedItem;
    /** List items. */
    private Vector items;
    /** Filtered items */
    private Vector originalItems;
    /** Filter string */
    private String filterString;
    /** Y-coordinate of the top of visible area. */
    private int translateY = 0;
    /** FrameAnimator instance for animating list scrolling. */
    private FrameAnimator animator;
    /** Flag for defining whether scrolling is active (gestures are handled differently while scrolling). */
    private boolean scrollingActive;
    /** Pending select gesture event that is stored for the duration of the select delay. */
    private int pendingGestureEvent;
    /** Select delay */
    private SelectDelay selectDelay;
    /** Thread used for the select delay. Reference kept to be able to cancel during delay. */
    private Thread delayThread;
    /** Last known list height */
    private int lastKnownListHeight = -1;
    /** The MIDlet's main display */
    private Display display;

    // Inner classes
    /**
     * Delay for showing selection focus to user before initiating action.
     */
    private class SelectDelay implements Runnable {

        /**
         * @see java.lang.Runnable#run()
         */
        public void run() {
            if (Log.TEST) Log.note("[SelectDelay#run]-->");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                if (Log.TEST) Log.note("[SelectDelay#run] interrupted");
                return;
            }
            if(pendingGestureEvent > 0){
                if (Log.TEST) Log.note("[SelectDelay#run] handling pending gesture");
                switch (pendingGestureEvent) {
                case GestureInteractiveZone.GESTURE_TAP:{
                    handleTap();
                    break;
                }
                case GestureInteractiveZone.GESTURE_LONG_PRESS:{
                    handleLongPress();
                    break;
                }

                default:
                    if (Log.TEST) Log.note("[SelectDelay#run] wrong type!");
                    break;
                }
                selectedItem.setSelected(false);
                selectedItem = null;
                pendingGestureEvent = 0;
                delayThread = null;
            }
        }
    }


    /**
     * Constructor.
     *
     * @param owner Parent object (e.g. Canvas or CustomItem).
     * @param tapActionId Action for short tap gesture.
     * @param longPressActionId Action for long press gesture.
     */
    public CustomList(CustomListOwner owner, short tapActionId, short longPressActionId, Display display)
    throws FavouriteArtistsException {

        if (Log.TEST) Log.note("[CustomList#CustomList]-->");

        this.display = display;
        this.owner = owner;
        this.tapActionId = tapActionId;
        this.longPressActionId = longPressActionId;
        this.items = new Vector();
        selectDelay = new SelectDelay();

        // First create a GestureInteractiveZone. The GestureInteractiveZone class is used to define an
        // area of the screen that reacts to a set of specified gestures.
        // The parameter GESTURE_ALL means that we want events for all gestures.
        GestureInteractiveZone zone = new GestureInteractiveZone(GestureInteractiveZone.GESTURE_ALL);
        // Next the GestureInteractiveZone is registered to registration manager. Note that multiple zones
        // can be registered for a single container, reference to the affected zone is passed in the event callback.
        if(GestureRegistrationManager.register(owner, zone) != true){
            throw new FavouriteArtistsException("GestureRegistrationManager.register() failed!");
        }
        // Add a listener for gesture events.
        GestureRegistrationManager.setListener(owner, this);

        // Create a FrameAnimator instance.
        animator = new FrameAnimator();

        // Use default values for maxFps an maxPps (zero param means that default is used).
        final short maxFps = 0;
        final short maxPps = 0;
        // Register the FrameAnimator. Animation uses the initial x & y coordinates as a starting point
        // i.e. the animate() callback will give coordinates in relation to this point.
        if(animator.register((short)0, (short)0, maxFps, maxPps, this) != true){
            throw new FavouriteArtistsException("GestureRegistrationManager.register() failed!");
        }
    }

    /**
     * Height of this list.
     */
    public void setHeight(int height){
        if (Log.TEST) Log.note("[CustomList#setHeight]-->");
        this.height = height;
    }

    /**
     * Appends an item to the list.
     *
     * @param item Item to append.
     */
    public void appendItem(ListItem item){
        if (Log.TEST) Log.note("[CustomList#appendItem]-->");
        items.addElement(item);
    }

    /**
     * Removes an item from the list.
     * @param item The item to remove
     */
    public void removeItem(ListItem item){
        if (Log.TEST) Log.note("[CustomList#appendItem]-->");
        items.removeElement(item);
        translateY = 0;
    }

    /**
     * Clears the list.
     */
    public void clearList(){
        if (Log.TEST) Log.note("[CustomList#clearList]-->");
        translateY = 0;
        selectedItem = null;
        items.removeAllElements();
        originalItems = null;
        filterString = null;
    }

    /**
     * Filters the visible list to items which' text1 contains the given search string.
     *
     * @param filterString Filter string.
     */
    public void filterList(String filterString){
        if (Log.TEST) Log.note("[CustomList#filterList]-->");
        if(filterString == null || filterString.length() == 0){
            return;
        }
        if (Log.TEST) Log.note("[CustomList#filterList] filterString: " + filterString );
        Vector sourceItems = null;
        if(this.filterString != null && this.filterString.startsWith(filterString) == true){
            if (Log.TEST) Log.note("[CustomList#filterList] using old filter");
            // Filter the existing set further
            this.filterString = filterString;
            sourceItems = items;
        } else {
            if (Log.TEST) Log.note("[CustomList#filterList] creating new filter");
            // Remove any existing filter
            if(this.filterString != null){
                this.filterString = null;
                items = originalItems;
            }
            // New filter
            this.filterString = filterString;
            // Store the original items
            originalItems = items;
            sourceItems = originalItems;
            // Create new vector for the filtered items.
            items = new Vector();
        }
        // Filter matching items
        for(int i = 0; i < sourceItems.size(); i++){
            ListItem item = (ListItem)sourceItems.elementAt(i);
            if(item.getText1().indexOf(filterString) >= 0){
                items.addElement(item);
            }
        }
        translateY = 0;
    }

    /**
     * Removes possible filter.
     */
    public void removeFilter(){
        if(originalItems != null){
            items = originalItems;
        }
        originalItems = null;
        filterString = null;
    }

    /**
     * Gets the selected item. Only valid during command handling.
     *
     * @return The selected item or null if nothing selected.
     */
    public ListItem getSelectedItem(){
        if (Log.TEST) Log.note("[CustomList#getSelectedItem]-->");
        return selectedItem;
    }

    /**
     * @see com.nokia.mid.ui.frameanimator.FrameAnimatorListener#animate(com.nokia.mid.ui.frameanimator.FrameAnimator, int, int, short, short, short, boolean)
     */
    public void animate(FrameAnimator animator, int x, int y, short delta, short deltaX, short deltaY, boolean lastFrame) {
        // In this example the y coordinate is used directly.
        translateY = y;
        // Scrolling is no longer active if this is the last frame
        scrollingActive = !lastFrame;
        // Request repaint from parent Displayable
        owner.requestRepaint();
    }

    /**
     * Stops scrolling.
     */
    private void stopScrolling() {
        scrollingActive = false;
        animator.stop();
    }

    /**
     * @see com.nokia.mid.ui.gestures.GestureListener#gestureAction(java.lang.Object, com.nokia.mid.ui.gestures.GestureInteractiveZone, com.nokia.mid.ui.gestures.GestureEvent)
     */
    public void gestureAction(Object container, GestureInteractiveZone zone, GestureEvent event) {

        // Block gesture handling if already handling one,
        // this may happen due to delayed handling of events (delay is needed for showing focus on selected item)
        if(pendingGestureEvent > 0){
            return;
        }

        switch (event.getType()) {
            case GestureInteractiveZone.GESTURE_DRAG:{
                if (Log.TEST) Log.note("[CustomList#gestureAction] drag");
                // In this example the drag gesture is directly used for altering the
                // visible area y-coordinate. Note that only the delta value is used. The reason for this
                // is that gesture event coordinates are screen coordinates and translateY is a list coordinate.
                // NOTE: Drag gestures are received in very rapid succession, the "whole"
                // drag (e.g. what the user would perceive as a complete drag gesture) usually ends when a
                // GESTURE_DROP event is received. In this example we don't need to know when user stops the drag,
                // so GESTURE_DROP is omitted.
                if (scrollingActive) {
                    stopScrolling();
                } else {
                    translateY += event.getDragDistanceY();
                    owner.requestRepaint();
                }
                break;
            }
            case GestureInteractiveZone.GESTURE_TAP:{
                if (Log.TEST) Log.note("[CustomList#gestureAction] tap");
                if (scrollingActive) {
                    // Stop scrolling first
                    stopScrolling();
                } else if (tapActionId != Actions.INVALID_ACTION_ID) {
                    // Select is initiated only when list is not scrolling.
                    // Handle gesture only if there's an action set for it
                    handleSelectEvent(event);
                }
                break;
            }
            case GestureInteractiveZone.GESTURE_FLICK:{
                if (Log.TEST) Log.note("[CustomList#gestureAction] flick");
                // Start vertical flick only if the gesture is more vertical than horizontal.
                float angle = Math.abs(event.getFlickDirection());
                if (Log.TEST) Log.note("[CustomList#gestureAction] flick angle: " + angle);
                if((angle > (Math.PI / 4) && angle < (3 * Math.PI / 4))){
                    if (Log.TEST) Log.note("[CustomList#gestureAction] flick accepted");
                    // Because we're using only vertical scrolling we use just the y speed.
                    int startSpeed = event.getFlickSpeedY();
                    int direction = FrameAnimator.FRAME_ANIMATOR_VERTICAL;
                    // This affects the deceleration of the scroll.
                    int friction = FrameAnimator.FRAME_ANIMATOR_FRICTION_LOW;
                    scrollingActive = true;
                    // Start the scroll, animate() callbacks will follow.
                    animator.kineticScroll(startSpeed, direction, friction, 0);
                }
                break;
            }
            case GestureInteractiveZone.GESTURE_LONG_PRESS:{
                if (Log.TEST) Log.note("[CustomList#gestureAction] long press");
                //Long press handling has mostly the same implementation as tap.
                if (scrollingActive) {
                    stopScrolling();
                } else if (longPressActionId != Actions.INVALID_ACTION_ID){
                    // Handle gesture only if there's an action set for it
                    handleSelectEvent(event);
                }
                break;
            }
            default:
                break;
        }
    }

    /**
     * Common handler function for selection events.
     *
     * @param event Gesture event.
     */
    private void handleSelectEvent(GestureEvent event){
        if (Log.TEST) Log.note("[CustomList#handleSelectEvent]-->");
        ListItem selected = getItemAt(event.getStartX(), event.getStartY());
        if (selected != null) {
            if (Log.TEST) Log.note("[CustomList#handleSelectEvent] got selected item");
            // Draw focus for the selected item
            selectedItem = selected;
            selected.setSelected(true);
            owner.requestRepaint();
            // Delay the event handling, so that user gets a chance to see the focus
            // Gesture is stored so that we can handle it later on.
            pendingGestureEvent = event.getType();
            delayThread = new Thread(selectDelay);
            delayThread.start();
        }
    }

    /**
     * Handler for tap gesture.
     */
    private void handleTap() {
        if (Log.TEST) {
            Log.note("[CustomList#handleTap]-->");
            animator.unregister();
        }
        // Delegate handling to owner
        owner.handleAction(tapActionId);
    }

    /**
     * Handler for long press gesture.
     */
    private void handleLongPress() {
         if (Log.TEST) Log.note("[CustomList#handleLongPress]-->");
         // Delegate handling to owner
        owner.handleAction(longPressActionId);
    }

    /**
     * Finds the item with the given coordinates.
     *
     * @param x X-coordinate.
     * @param y Y-coordinate.
     * @return Item at the given coordinates or null if no item found.
     */
    private ListItem getItemAt(int x, int y) {
        y -= translateY;
        ListItem item = null;
        int heightSoFar = 0;
        int heightNext = 0;

        // Go through all items (pretend that list item heights can vary).
        for (int i = 0; i < items.size(); i++) {
            item = (ListItem) items.elementAt(i);
            heightNext += item.getHeight();

            if (y >= heightSoFar && y <= heightNext) {
                return item;
            }

            heightSoFar += item.getHeight();
        }

        return null;
    }

    /**
     * @see javax.microedition.lcdui.Canvas#paint(javax.microedition.lcdui.Graphics)
     */
    protected void paint(Graphics g) {
         if (Log.TEST) Log.note("[CustomList#paint] ");

        if (translateY > 0) {
            // Trying to scroll beyond list start.
            translateY = 0;

            if (scrollingActive) {
                stopScrolling();
            }

        } else if (lastKnownListHeight != -1 && (translateY + lastKnownListHeight) < height) {
            // Trying to scroll beyond list end.
            translateY = -lastKnownListHeight + height;

            if (scrollingActive) {
                stopScrolling();
            }
        }
        if (Log.TEST) Log.note("[CustomList#paint] translateY: " + translateY);
        g.translate(0, translateY);

        ListItem item = null;
        int yOffset = 0;
        int yOffsetNext = 0;
        int y0 = -translateY;
        int y1 = y0 + height;
        int i = 0;

        g.setColor(display.getColor(Display.COLOR_BACKGROUND));
        g.fillRect(0, 0, g.getClipWidth(), g.getClipHeight()*items.size());
        g.setColor(display.getColor(Display.COLOR_FOREGROUND));

        for (; i < items.size(); i++) {
            item = (ListItem) items.elementAt(i);
            yOffsetNext += item.getHeight();

            if (yOffsetNext < y0) {
                // Item is not visible -> skip drawing it.
                yOffset = yOffsetNext;
                continue;

            } else if (yOffset > y1) {
                // Item would be drawn "under" the visible area -> stop drawing.
                break;
            }
            if (Log.TEST) Log.note("[CustomList#paint] yOffset: " + yOffset);
            item.paint(g, yOffset);
            yOffset = yOffsetNext;
        }

        if (i == items.size()) {
            lastKnownListHeight = yOffset;
        }
    }
}