com.google.collide.client.ui.menu.PositionController.java Source code

Java tutorial

Introduction

Here is the source code for com.google.collide.client.ui.menu.PositionController.java

Source

// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.collide.client.ui.menu;

import com.google.collide.client.AppContext;
import com.google.collide.client.util.CssUtils;
import com.google.collide.client.util.Elements;
import com.google.collide.client.util.RelativeClientRect;
import com.google.common.base.Preconditions;
import com.google.gwt.user.client.Window;

import elemental.css.CSSStyleDeclaration;
import elemental.css.CSSStyleDeclaration.Unit;
import elemental.html.ClientRect;
import elemental.html.Element;

/**
 * A controller which handles positioning an element relative to another element. This controller is
 * aware of the screen position and will attempt to keep an element on the screen if it would
 * otherwise run off. As an additional wrinkle, if flipping it does not produce a valid screen
 * position it will just set the offending dimension to 0 .
 *
 */
/*
 * TODO: In the case where things don't fit on screen we perform a few extra layouts, this
 * could be fixed by offsetting the original rect until we find a position that works then
 * performing a second layout to actually move it.
 */
public class PositionController {

    /**
     * A builder which specifies positioning options for a {@link PositionController}.It defaults to
     * using {@link VerticalAlign#TOP}, {@link HorizontalAlign#LEFT}, and {@link Position#OVERLAP}.
     */
    public static class PositionerBuilder {

        private VerticalAlign verticalAlignment = VerticalAlign.TOP;
        private HorizontalAlign horizontalAlignment = HorizontalAlign.LEFT;
        private Position position = Position.OVERLAP;

        public PositionerBuilder setVerticalAlign(VerticalAlign verticalAlignment) {
            this.verticalAlignment = verticalAlignment;
            return this;
        }

        public PositionerBuilder setHorizontalAlign(HorizontalAlign horizontalAlignment) {
            this.horizontalAlignment = horizontalAlignment;
            return this;
        }

        public PositionerBuilder setPosition(Position position) {
            this.position = position;
            return this;
        }

        /**
         * Creates a positioner which positions an element next to the provided anchor using gwt_root as
         * the container. This is the preferred method of positioning if the element is not going to
         * deal with scrolling in anyway and just needs to be positioned around an element.
         */
        public Positioner buildAnchorPositioner(Element anchor) {
            return new AnchorPositioner(anchor, verticalAlignment, horizontalAlignment, position);
        }

        /**
         * Creates a positioner which positions an element next to the provided anchor using the anchors
         * offsetParent as the container. This is the preferred method of positioning if the parent is a
         * scrollable container.
         */
        public Positioner buildOffsetFromParentPositioner(Element anchor) {
            return new OffsetPositioner(anchor, OffsetPositioner.ANCHOR_OFFSET_PARENT, verticalAlignment,
                    horizontalAlignment, position);
        }

        /**
         * Creates a positioner which positions an element next to the provided anchor using the
         * supplied ancestor as the container. This is the preferred method of positioning if the
         * ancestor is a scrollable and anchor is not a direct child.
         */
        public Positioner buildOffsetFromAncestorPositioner(Element anchor, Element ancestor) {
            return new OffsetPositioner(anchor, ancestor, verticalAlignment, horizontalAlignment, position);
        }

        /**
         * Creates a positioner which appends elements to the body of the document allowing positioning
         * absolutely at a given point in the viewport (typically mouse coordinates).
         */
        public Positioner buildMousePositioner() {
            return new MousePositioner(verticalAlignment, horizontalAlignment, position);
        }
    }

    /**
     * The base functionality of a positioner.
     */
    public abstract static class Positioner {
        private final VerticalAlign verticalAlignment;
        private final HorizontalAlign horizontalAlignment;
        private final Position position;

        private VerticalAlign curVerticalAlignment;
        private HorizontalAlign curHorizontalAlignment;

        public Positioner(VerticalAlign verticalAlignment, HorizontalAlign horizontalAlignment, Position position) {
            this.verticalAlignment = verticalAlignment;
            this.horizontalAlignment = horizontalAlignment;
            this.position = position;

            revert();
        }

        public VerticalAlign getVerticalAlignment() {
            return curVerticalAlignment;
        }

        public HorizontalAlign getHorizontalAlignment() {
            return curHorizontalAlignment;
        }

        public Position getPosition() {
            return position;
        }

        /** Appends an element to the appropriate place in the DOM for this positioner */
        abstract void appendElementToContainer(Element element);

        /**
         * Returns the minimum width that should be used by elements being positioned by this
         * positioner. The meaning of this value varies depending on the implementation.
         */
        public abstract int getMinimumWidth();

        abstract double getTop(ClientRect elementRect, int y);

        abstract double getLeft(ClientRect elementRect, int x);

        void flip(VerticalAlign verticalAlignment, HorizontalAlign horizontalAlignment) {
            curVerticalAlignment = verticalAlignment;
            curHorizontalAlignment = horizontalAlignment;
        }

        void revert() {
            curVerticalAlignment = verticalAlignment;
            curHorizontalAlignment = horizontalAlignment;
        }
    }

    /**
     * A positioner which positions an element next to another element.
     */
    public static class AnchorPositioner extends Positioner {

        private final Element anchor;

        private AnchorPositioner(Element anchor, VerticalAlign verticalAlignment,
                HorizontalAlign horizontalAlignment, Position position) {
            super(verticalAlignment, horizontalAlignment, position);
            this.anchor = anchor;
        }

        /**
         * @return the width of the anchor used by this {@link Positioner}.
         */
        @Override
        public int getMinimumWidth() {
            return (int) anchor.getBoundingClientRect().getWidth();
        }

        /** Appends the element to the body of the DOM */
        @Override
        void appendElementToContainer(Element element) {
            Element gwt = Elements.getElementById(AppContext.GWT_ROOT);
            gwt.appendChild(element);
        }

        @Override
        double getTop(ClientRect elementRect, int offsetY) {
            Element gwt = Elements.getElementById(AppContext.GWT_ROOT);
            ClientRect anchorRect = RelativeClientRect.relativeToRect(gwt.getBoundingClientRect(),
                    anchor.getBoundingClientRect());

            switch (getVerticalAlignment()) {
            case TOP:
                return anchorRect.getTop() - elementRect.getHeight() - offsetY;
            case MIDDLE:
                double anchory = anchorRect.getTop() + anchorRect.getHeight() / 2;
                return anchory - elementRect.getHeight() / 2 - offsetY;
            case BOTTOM:
                return anchorRect.getBottom() + offsetY;
            default:
                return 0;
            }
        }

        @Override
        double getLeft(ClientRect elementRect, int offsetX) {
            Element gwt = Elements.getElementById(AppContext.GWT_ROOT);
            ClientRect anchorRect = RelativeClientRect.relativeToRect(gwt.getBoundingClientRect(),
                    anchor.getBoundingClientRect());

            switch (getHorizontalAlignment()) {
            case LEFT:
                if (getPosition() == Position.OVERLAP) {
                    return anchorRect.getLeft() + offsetX;
                } else {
                    return anchorRect.getLeft() - elementRect.getWidth() - offsetX;
                }
            case MIDDLE:
                double anchorx = anchorRect.getLeft() + anchorRect.getWidth() / 2;
                return anchorx - elementRect.getWidth() / 2 - offsetX;
            case RIGHT:
                if (getPosition() == Position.OVERLAP) {
                    return anchorRect.getRight() - elementRect.getWidth() - offsetX;
                } else {
                    return anchorRect.getRight() + offsetX;
                }
            default:
                return 0;
            }
        }
    }

    public static class OffsetPositioner extends Positioner {

        /** Indicates that the offsetParent of the anchor should be used for positioning. */
        public static final Element ANCHOR_OFFSET_PARENT = Elements.createDivElement();

        private final Element anchor;
        private final Element offsetAncestor;
        private double anchorOffsetTop = -1;
        private double anchorOffsetLeft = -1;

        private OffsetPositioner(Element anchor, Element ancestor, VerticalAlign verticalAlignment,
                HorizontalAlign horizontalAlignment, Position position) {
            super(verticalAlignment, horizontalAlignment, position);
            this.anchor = anchor;
            this.offsetAncestor = ancestor;
        }

        /**
         * @return the width of the anchor used by this {@link Positioner}.
         */
        @Override
        public int getMinimumWidth() {
            return (int) anchor.getBoundingClientRect().getWidth();
        }

        /** Appends the element to the specified ancestor of the anchor. */
        @Override
        void appendElementToContainer(Element element) {
            Element container = getOffsetAnchestorForAnchor();
            container.appendChild(element);
        }

        @Override
        double getTop(ClientRect elementRect, int offsetY) {
            // This rect is to only be used for width and height since the coordinates are relative to the
            // viewport and we are relative to an offsetParent.
            ClientRect anchorRect = anchor.getBoundingClientRect();

            ensureOffsetCalculated();
            switch (getVerticalAlignment()) {
            case TOP:
                return anchorOffsetTop - elementRect.getHeight() - offsetY;
            case MIDDLE:
                double anchory = anchorOffsetTop + anchorRect.getHeight() / 2;
                return anchory - elementRect.getHeight() / 2 - offsetY;
            case BOTTOM:
                double anchorBottom = anchorOffsetTop + anchorRect.getHeight();
                return anchorBottom + offsetY;
            default:
                return 0;
            }
        }

        @Override
        double getLeft(ClientRect elementRect, int offsetX) {
            // This rect is to only be used for width and height since the coordinates are relative to the
            // viewport and we are relative to an offsetParent.
            ClientRect anchorRect = anchor.getBoundingClientRect();

            ensureOffsetCalculated();
            switch (getHorizontalAlignment()) {
            case LEFT:
                if (getPosition() == Position.OVERLAP) {
                    return anchorOffsetLeft + offsetX;
                } else {
                    return anchorOffsetLeft - elementRect.getWidth() - offsetX;
                }
            case MIDDLE:
                double anchorx = anchorOffsetLeft + anchorRect.getWidth() / 2;
                return anchorx - elementRect.getWidth() / 2 - offsetX;
            case RIGHT:
                double anchorRight = anchorOffsetLeft + anchorRect.getWidth();
                if (getPosition() == Position.OVERLAP) {
                    return anchorRight - elementRect.getWidth() - offsetX;
                } else {
                    return anchorRight + offsetX;
                }
            default:
                return 0;
            }
        }

        private Element getOffsetAnchestorForAnchor() {
            Element e = offsetAncestor == ANCHOR_OFFSET_PARENT ? anchor.getOffsetParent() : offsetAncestor;
            return e == null ? Elements.getBody() : e;
        }

        private void ensureOffsetCalculated() {
            if (anchorOffsetTop >= 0 && anchorOffsetLeft >= 0) {
                return;
            }

            Element ancestor = getOffsetAnchestorForAnchor();
            anchorOffsetTop = anchorOffsetLeft = 0;
            for (Element e = anchor; e != ancestor; e = e.getOffsetParent()) {
                Preconditions.checkNotNull(e, "Offset parent specified is not in ancestory chain");
                anchorOffsetTop += e.getOffsetTop();
                anchorOffsetLeft += e.getOffsetLeft();
            }
        }
    }

    /**
     * A positioner which positions directly next to a point such as the mouse.
     */
    public static class MousePositioner extends Positioner {
        private MousePositioner(VerticalAlign verticalAlignment, HorizontalAlign horizontalAlignment,
                Position position) {
            super(verticalAlignment, horizontalAlignment, position);
        }

        /**
         * @returns 0 since this is being positioned next to the mouse.
         */
        @Override
        public int getMinimumWidth() {
            return 0;
        }

        /** Appends the element to the document body */
        @Override
        void appendElementToContainer(Element element) {
            Elements.getBody().appendChild(element);
        }

        @Override
        double getTop(ClientRect elementRect, int mouseY) {
            double top;
            switch (getVerticalAlignment()) {
            case TOP:
                top = mouseY - elementRect.getHeight();
                break;
            case MIDDLE:
                top = mouseY - elementRect.getHeight() / 2;
                break;
            case BOTTOM:
            default:
                top = mouseY;
                break;
            }
            return top;
        }

        @Override
        double getLeft(ClientRect elementRect, int mouseX) {
            double left;
            switch (getHorizontalAlignment()) {
            case LEFT:
                left = mouseX;
                break;
            case MIDDLE:
                left = mouseX - elementRect.getWidth() / 2;
                break;
            case RIGHT:
            default:
                left = mouseX - elementRect.getWidth();
                break;
            }
            return left;
        }
    }

    public enum VerticalAlign {
        /**
         * Aligns the bottom of the element to the top of the anchor.
         */
        TOP,
        /**
         * Aligns the top of the element to the bottom of the anchor.
         */
        BOTTOM,
        /**
         * Aligns the middle of the element to the middle of the anchor.
         */
        MIDDLE,
    }

    public enum HorizontalAlign {
        /**
         * Aligns to the left side of the anchor.
         */
        LEFT,
        /**
         * Aligns to the horizontal middle of the anchor.
         */
        MIDDLE,
        /**
         * Aligns to the right side of the anchor.
         */
        RIGHT,
    }

    /**
     * Changes the position of the element. If the element is aligned with the RIGHT or LEFT side of
     * the anchor, Position will determine whether or not the element overlaps the anchor.
     */
    public enum Position {
        OVERLAP, NO_OVERLAP
    }

    /**
     * Used to specify that a value should be ignored by
     * {@link #setElementLeftAndTop(double, double)}.
     */
    private final static double IGNORE = -1;

    private final Element element;
    private final Positioner elementPositioner;

    public PositionController(Positioner positioner, Element element) {
        this.elementPositioner = positioner;
        this.element = element;
    }

    /**
     * Updates the element's position to move it to the correct location.
     */
    public void updateElementPosition() {
        updateElementPosition(0, 0);
    }

    /**
     * Updates the element's position. If this controller is aligning next to an anchor then x and y
     * will be offsets, otherwise they will be treated as the absolute x and y position to align to.
     *
     * <p>
     * Note: If used as offsets x and y are relative to the aligned edge i.e. if you are aligned to
     * the right then x moves you left vs aligning to the left where x moves you to the right.
     * </p>
     */
    public void updateElementPosition(int x, int y) {
        place(x, y);
        // check if we're at a valid place, if not temporarily flip the positioner
        // and place again.
        VerticalAlign originalVertical = elementPositioner.getVerticalAlignment();
        HorizontalAlign originalHorizontal = elementPositioner.getHorizontalAlignment();

        if (!checkPositionValidAndMaybeUpdatePositioner()) {
            place(x, y);

            boolean wasVerticalFlipped = originalVertical != elementPositioner.getVerticalAlignment();
            boolean wasHorizontalFlipped = originalHorizontal != elementPositioner.getHorizontalAlignment();

            // Check if the new position is valid,
            if (!checkPositionValidAndMaybeUpdatePositioner()) {
                /*
                 * We try to make our best move here, if the element is off the screen in both dimensions
                 * then the window is tiny and we try to move it to 0,0. if it's only one dimensions we move
                 * it to either the top or left of the screen.
                 */
                if (wasVerticalFlipped) {
                    setElementLeftAndTop(IGNORE, 0);
                }
                if (wasHorizontalFlipped) {
                    setElementLeftAndTop(0, IGNORE);
                }
            }
        }

        // revert any temporary changes made to our positioner
        elementPositioner.revert();
    }

    public Positioner getPositioner() {
        return elementPositioner;
    }

    /**
     * Checks if the element is completely visible on the screen, if not it will temporarily flip our
     * {@link #elementPositioner} with updated alignment values which might work to fix the problem.
     */
    private boolean checkPositionValidAndMaybeUpdatePositioner() {
        // recalculate the element's dimensions and check to see if any of the edges
        // of the element are outside the window
        ClientRect elementRect = ensureVisibleAndGetRect(element);

        VerticalAlign updatedVerticalAlign = elementPositioner.getVerticalAlignment();
        HorizontalAlign updatedHorizontalAlign = elementPositioner.getHorizontalAlignment();

        if (elementRect.getBottom() > Window.getClientHeight()) {
            updatedVerticalAlign = VerticalAlign.TOP;
        } else if (elementRect.getTop() < 0) {
            updatedVerticalAlign = VerticalAlign.BOTTOM;
        }

        if (elementRect.getRight() > Window.getClientWidth()) {
            updatedHorizontalAlign = HorizontalAlign.RIGHT;
        } else if (elementRect.getLeft() < 0) {
            updatedHorizontalAlign = HorizontalAlign.LEFT;
        }

        if (updatedVerticalAlign != elementPositioner.getVerticalAlignment()
                || updatedHorizontalAlign != elementPositioner.getHorizontalAlignment()) {
            elementPositioner.flip(updatedVerticalAlign, updatedHorizontalAlign);
            return false;
        }
        return true;
    }

    /**
     * Place the element based on the given information.
     *
     * @param x the offset or location depending on the underlying positioner.
     * @param y the offset or location depending on the underlying positioner.
     */
    private void place(int x, int y) {
        resetElementPosition();

        ClientRect elementRect = ensureVisibleAndGetRect(element);
        double left = elementPositioner.getLeft(elementRect, x);
        double top = elementPositioner.getTop(elementRect, y);

        setElementLeftAndTop(left, top);
    }

    /**
     * Sets an elements left and top to the provided values.
     */
    private void setElementLeftAndTop(double left, double top) {
        CSSStyleDeclaration style = element.getStyle();
        if (left != IGNORE) {
            style.setLeft(left, Unit.PX);
        }
        if (top != IGNORE) {
            style.setTop(top, Unit.PX);
        }
    }

    /**
     * Resets an element's position by removing top/right/bottom/left and setting position to
     * absolute.
     */
    private void resetElementPosition() {
        CSSStyleDeclaration style = element.getStyle();
        style.setPosition("absolute");
        style.clearTop();
        style.clearRight();
        style.clearBottom();
        style.clearLeft();

        elementPositioner.appendElementToContainer(element);
    }

    /**
     * Ensures that an element is not display: none and is just visibility hidden so we can get an
     * accurate client rect.
     */
    private static ClientRect ensureVisibleAndGetRect(Element element) {
        // Try to get rect and see if it isn't all 0's
        ClientRect rect = element.getBoundingClientRect();
        double rectSum = rect.getBottom() + rect.getTop() + rect.getLeft() + rect.getRight() + rect.getHeight()
                + rect.getWidth();
        if (rectSum != 0) {
            return rect;
        }

        // We make an attempt to get an accurate measurement of the element
        CSSStyleDeclaration style = element.getStyle();
        String visibility = CssUtils.setAndSaveProperty(element, "visibility", "hidden");
        String display = style.getDisplay();

        // if display set to none we remove it and let its normal style show through
        if (style.getDisplay().equals("none")) {
            style.removeProperty("display");
        } else {
            // it's likely display: none in a css class so we just have to guess.
            // We guess display:block since that's common on containers.
            style.setDisplay("block");
        }
        rect = element.getBoundingClientRect();
        style.setDisplay(display);
        style.setVisibility(visibility);

        return rect;
    }
}