DesignTool.java

/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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 androidx.constraintlayout.motion.widget;

import static androidx.constraintlayout.widget.ConstraintSet.BASELINE;
import static androidx.constraintlayout.widget.ConstraintSet.BOTTOM;
import static androidx.constraintlayout.widget.ConstraintSet.END;
import static androidx.constraintlayout.widget.ConstraintSet.HORIZONTAL;
import static androidx.constraintlayout.widget.ConstraintSet.LEFT;
import static androidx.constraintlayout.widget.ConstraintSet.RIGHT;
import static androidx.constraintlayout.widget.ConstraintSet.START;
import static androidx.constraintlayout.widget.ConstraintSet.TOP;
import static androidx.constraintlayout.widget.ConstraintSet.VERTICAL;
import static androidx.constraintlayout.widget.ConstraintSet.WRAP_CONTENT;

import android.util.Log;
import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;

import androidx.constraintlayout.widget.ConstraintSet;

import java.util.HashMap;
import java.util.Objects;

/**
 * Utility class to manipulate MotionLayout from the layout editor
 *
 *
 */
public class DesignTool {

    static final HashMap<Pair<Integer, Integer>, String> sAllAttributes = new HashMap<>();
    static final HashMap<String, String> sAllMargins = new HashMap<>();
    private static final boolean DEBUG = false;
    private static final boolean DO_NOT_USE = false;
    private static final String TAG = "DesignTool";

    static {
        sAllAttributes.put(Pair.create(BOTTOM, BOTTOM), "layout_constraintBottom_toBottomOf");
        sAllAttributes.put(Pair.create(BOTTOM, TOP), "layout_constraintBottom_toTopOf");
        sAllAttributes.put(Pair.create(TOP, BOTTOM), "layout_constraintTop_toBottomOf");
        sAllAttributes.put(Pair.create(TOP, TOP), "layout_constraintTop_toTopOf");
        sAllAttributes.put(Pair.create(START, START), "layout_constraintStart_toStartOf");
        sAllAttributes.put(Pair.create(START, END), "layout_constraintStart_toEndOf");
        sAllAttributes.put(Pair.create(END, START), "layout_constraintEnd_toStartOf");
        sAllAttributes.put(Pair.create(END, END), "layout_constraintEnd_toEndOf");
        sAllAttributes.put(Pair.create(LEFT, LEFT), "layout_constraintLeft_toLeftOf");
        sAllAttributes.put(Pair.create(LEFT, RIGHT), "layout_constraintLeft_toRightOf");
        sAllAttributes.put(Pair.create(RIGHT, RIGHT), "layout_constraintRight_toRightOf");
        sAllAttributes.put(Pair.create(RIGHT, LEFT), "layout_constraintRight_toLeftOf");
        sAllAttributes.put(Pair.create(BASELINE, BASELINE),
                "layout_constraintBaseline_toBaselineOf");

        sAllMargins.put("layout_constraintBottom_toBottomOf", "layout_marginBottom");
        sAllMargins.put("layout_constraintBottom_toTopOf", "layout_marginBottom");
        sAllMargins.put("layout_constraintTop_toBottomOf", "layout_marginTop");
        sAllMargins.put("layout_constraintTop_toTopOf", "layout_marginTop");
        sAllMargins.put("layout_constraintStart_toStartOf", "layout_marginStart");
        sAllMargins.put("layout_constraintStart_toEndOf", "layout_marginStart");
        sAllMargins.put("layout_constraintEnd_toStartOf", "layout_marginEnd");
        sAllMargins.put("layout_constraintEnd_toEndOf", "layout_marginEnd");
        sAllMargins.put("layout_constraintLeft_toLeftOf", "layout_marginLeft");
        sAllMargins.put("layout_constraintLeft_toRightOf", "layout_marginLeft");
        sAllMargins.put("layout_constraintRight_toRightOf", "layout_marginRight");
        sAllMargins.put("layout_constraintRight_toLeftOf", "layout_marginRight");
    }

    private final MotionLayout mMotionLayout;
    private MotionScene mSceneCache;
    private String mLastStartState = null;
    private String mLastEndState = null;
    private int mLastStartStateId = -1;
    private int mLastEndStateId = -1;

    public DesignTool(MotionLayout motionLayout) {
        mMotionLayout = motionLayout;
    }

    private static int getPxFromDp(int dpi, String value) {
        if (value == null) {
            return 0;
        }
        int index = value.indexOf('d');
        if (index == -1) {
            return 0;
        }
        String filteredValue = value.substring(0, index);
        int dpValue = (int) (Integer.valueOf(filteredValue) * dpi / 160f);
        return dpValue;
    }

    private static void connect(int dpi,
                                ConstraintSet set,
                                View view,
                                HashMap<String, String> attributes,
                                int from,
                                int to) {
        String connection = sAllAttributes.get(Pair.create(from, to));
        String connectionValue = attributes.get(connection);

        if (connectionValue != null) {
            int marginValue = 0;
            String margin = sAllMargins.get(connection);
            if (margin != null) {
                marginValue = getPxFromDp(dpi, attributes.get(margin));
            }
            int id = Integer.parseInt(connectionValue);
            set.connect(view.getId(), from, id, to, marginValue);
        }
    }

    private static void setBias(ConstraintSet set,
                                View view,
                                HashMap<String, String> attributes,
                                int type) {
        String bias = "layout_constraintHorizontal_bias";
        if (type == VERTICAL) {
            bias = "layout_constraintVertical_bias";
        }
        String biasValue = attributes.get(bias);
        if (biasValue != null) {
            if (type == HORIZONTAL) {
                set.setHorizontalBias(view.getId(), Float.parseFloat(biasValue));
            } else if (type == VERTICAL) {
                set.setVerticalBias(view.getId(), Float.parseFloat(biasValue));
            }
        }
    }

    private static void setDimensions(int dpi,
                                      ConstraintSet set,
                                      View view,
                                      HashMap<String, String> attributes,
                                      int type) {
        String dimension = "layout_width";
        if (type == VERTICAL) {
            dimension = "layout_height";
        }
        String dimensionValue = attributes.get(dimension);
        if (dimensionValue != null) {
            int value = WRAP_CONTENT;
            if (!dimensionValue.equalsIgnoreCase("wrap_content")) {
                value = getPxFromDp(dpi, dimensionValue);
            }
            if (type == HORIZONTAL) {
                set.constrainWidth(view.getId(), value);
            } else {
                set.constrainHeight(view.getId(), value);
            }
        }
    }

    private static void setAbsolutePositions(int dpi,
                                             ConstraintSet set,
                                             View view,
                                             HashMap<String, String> attributes) {
        String absoluteX = attributes.get("layout_editor_absoluteX");
        if (absoluteX != null) {
            set.setEditorAbsoluteX(view.getId(), getPxFromDp(dpi, absoluteX));
        }
        String absoluteY = attributes.get("layout_editor_absoluteY");
        if (absoluteY != null) {
            set.setEditorAbsoluteY(view.getId(), getPxFromDp(dpi, absoluteY));
        }
    }

    /**
     * Get the center point of the animation path of a view
     *
     * @param view view to getMap the animation of
     * @param path array to be filled (x1,y1,x2,y2...)
     * @return -1 if not under and animation 0 if not animated or number of point along animation
     */
    public int getAnimationPath(Object view, float[] path, int len) {
        if (mMotionLayout.mScene == null) {
            return -1;
        }

        MotionController motionController = mMotionLayout.mFrameArrayList.get(view);
        if (motionController == null) {
            return 0;
        }

        motionController.buildPath(path, len);
        return len;
    }

    /**
     * Get the center point of the animation path of a view
     *
     * @param view view to getMap the animation of
     * @param path array to be filled (in groups of 8) (x1,y1,x2,y2...)
     */
    public void getAnimationRectangles(Object view, float[] path) {
        if (mMotionLayout.mScene == null) {
            return;
        }
        int duration = mMotionLayout.mScene.getDuration();
        int frames = duration / 16;

        MotionController motionController = mMotionLayout.mFrameArrayList.get(view);
        if (motionController == null) {
            return;
        }

        motionController.buildRectangles(path, frames);
    }

    /**
     * Get the location of the start end and key frames
     *
     * @param view the view to track
     * @param key  array to be filled
     * @return number of key frames + 2
     */
    public int getAnimationKeyFrames(Object view, float[] key) {
        if (mMotionLayout.mScene == null) {
            return -1;
        }
        int duration = mMotionLayout.mScene.getDuration();
        int frames = duration / 16;

        MotionController motionController = mMotionLayout.mFrameArrayList.get(view);
        if (motionController == null) {
            return 0;
        }

        motionController.buildKeyFrames(key, null);
        return frames;
    }

    /**
     * @param position
     *
     */
    public void setToolPosition(float position) {
        if (mMotionLayout.mScene == null) {
            mMotionLayout.mScene = mSceneCache;
        }
        mMotionLayout.setProgress(position);
        mMotionLayout.evaluate(true);
        mMotionLayout.requestLayout();
        mMotionLayout.invalidate();
    }

    // @TODO: add description

    /**
     *
     * @return
     */
    public String getStartState() {
        int startId = mMotionLayout.getStartState();
        if (mLastStartStateId == startId) {
            return mLastStartState;
        }
        String last = mMotionLayout.getConstraintSetNames(startId);

        if (last != null) {
            mLastStartState = last;
            mLastStartStateId = startId;
        }
        return mMotionLayout.getConstraintSetNames(startId);
    }

    // @TODO: add description

    /**
     *
     * @return
     */
    public String getEndState() {
        int endId = mMotionLayout.getEndState();

        if (mLastEndStateId == endId) {
            return mLastEndState;
        }
        String last = mMotionLayout.getConstraintSetNames(endId);
        if (last != null) {
            mLastEndState = last;
            mLastEndStateId = endId;
        }
        return last;
    }

    /**
     * Return the current progress of the current transition
     *
     * @return current transition's progress
     */
    public float getProgress() {
        return mMotionLayout.getProgress();
    }

    /**
     * Return the current state (ConstraintSet id) as a string
     *
     * @return the last state set via the design tool bridge
     */
    public String getState() {
        if (mLastStartState != null && mLastEndState != null) {
            float progress = getProgress();
            float epsilon = 0.01f;
            if (progress <= epsilon) {
                return mLastStartState;
            } else if (progress >= 1 - epsilon) {
                return mLastEndState;
            }
        }
        return mLastStartState;
    }

    /**
     * This sets the constraint set based on a string. (without the "@+id/")
     *
     * @param id
     */
    public void setState(String id) {
        if (id == null) {
            id = "motion_base";
        }
        if (Objects.equals(mLastStartState, id)) {
            return;
        }

        if (DEBUG) {
            System.out.println("================================");
            dumpConstraintSet(id);
        }

        mLastStartState = id;
        mLastEndState = null;
        if (id == null && DO_NOT_USE) { // going to base layout
            if (mMotionLayout.mScene != null) {
                mSceneCache = mMotionLayout.mScene;
                mMotionLayout.mScene = null;
            }

            mMotionLayout.setProgress(0);
            mMotionLayout.requestLayout();
        }

        if (mMotionLayout.mScene == null) {
            mMotionLayout.mScene = mSceneCache;
        }

        int rscId = mMotionLayout.lookUpConstraintId(id);
        mLastStartStateId = rscId;

        if (rscId != 0) {
            if (rscId == mMotionLayout.getStartState()) {
                mMotionLayout.setProgress(0);
            } else if (rscId == mMotionLayout.getEndState()) {
                mMotionLayout.setProgress(1);
            } else {
                mMotionLayout.transitionToState(rscId);
                mMotionLayout.setProgress(1);
            }
        }
        mMotionLayout.requestLayout();
    }

    /**
     * Utility method, returns true if we are currently in a transition
     *
     * @return true if in a transition, false otherwise
     */
    public boolean isInTransition() {
        return mLastStartState != null && mLastEndState != null;
    }

    /**
     * This sets the constraint set based on a string. (without the "@+id/")
     *
     * @param start
     * @param end
     */
    public void setTransition(String start, String end) {
        if (mMotionLayout.mScene == null) {
            mMotionLayout.mScene = mSceneCache;
        }
        int startId = mMotionLayout.lookUpConstraintId(start);
        int endId = mMotionLayout.lookUpConstraintId(end);

        mMotionLayout.setTransition(startId, endId);
        mLastStartStateId = startId;
        mLastEndStateId = endId;

        mLastStartState = start;
        mLastEndState = end;
    }

    /**
     * this allow disabling autoTransitions to prevent design surface
     * from being in undefined states
     *
     * @param disable
     */
    public void disableAutoTransition(boolean disable) {
        mMotionLayout.disableAutoTransition(disable);
    }

    /**
     * Gets the time of the currently set animation.
     *
     * @return time in Milliseconds
     */
    public long getTransitionTimeMs() {
        return mMotionLayout.getTransitionTimeMs();
    }

    /**
     * Get the keyFrames for the view controlled by this MotionController.
     * The call is designed to be efficient because it will be called 30x Number of views a second
     *
     * @param view the view to return keyframe positions
     * @param type is pos(0-100) + 1000*mType(1=Attrib, 2=Position, 3=TimeCycle 4=Cycle 5=Trigger
     * @param pos  the x&y position of the keyFrame along the path
     * @return Number of keyFrames found
     */
    public int getKeyFramePositions(Object view, int[] type, float[] pos) {
        MotionController controller = mMotionLayout.mFrameArrayList.get((View) view);
        if (controller == null) {
            return 0;
        }
        return controller.getKeyFramePositions(type, pos);
    }

    /**
     * Get the keyFrames for the view controlled by this MotionController.
     * The call is designed to be efficient because it will be called 30x Number of views a second
     *
     * @param view the view to return keyframe positions
     * @param info
     * @return Number of keyFrames found
     */
    public int getKeyFrameInfo(Object view, int type, int[] info) {
        MotionController controller = mMotionLayout.mFrameArrayList.get((View) view);
        if (controller == null) {
            return 0;
        }
        return controller.getKeyFrameInfo(type, info);
    }

    /**
     * @param view
     * @param type
     * @param x
     * @param y
     * @return
     *
     */
    public float getKeyFramePosition(Object view, int type, float x, float y) {
        if (!(view instanceof View)) {
            return 0f;
        }

        MotionController mc = mMotionLayout.mFrameArrayList.get((View) view);
        if (mc == null) {
            return 0f;
        }

        return mc.getKeyFrameParameter(type, x, y);
    }

    /**
     * @param view
     * @param position
     * @param name
     * @param value
     *
     */
    public void setKeyFrame(Object view, int position, String name, Object value) {
        if (DEBUG) {
            Log.v(TAG, "setKeyFrame " + position + " <" + name + "> " + value);
        }
        if (mMotionLayout.mScene != null) {
            mMotionLayout.mScene.setKeyframe((View) view, position, name, value);
            mMotionLayout.mTransitionGoalPosition = position / 100f;
            mMotionLayout.mTransitionLastPosition = 0;
            mMotionLayout.rebuildScene();
            mMotionLayout.evaluate(true);
        }
    }

    /**
     * Move the widget directly
     *
     * @param view
     * @param position
     * @param type
     * @param x
     * @param y
     * @return
     *
     */
    public boolean setKeyFramePosition(Object view, int position, int type, float x, float y) {
        if (!(view instanceof View)) {
            return false;
        }

        if (mMotionLayout.mScene != null) {
            MotionController controller = mMotionLayout.mFrameArrayList.get(view);
            position = (int) (mMotionLayout.mTransitionPosition * 100);
            if (controller != null
                    && mMotionLayout.mScene.hasKeyFramePosition((View) view, position)) {
                float fx = controller.getKeyFrameParameter(MotionController.HORIZONTAL_PATH_X,
                        x, y);
                float fy = controller.getKeyFrameParameter(MotionController.VERTICAL_PATH_Y,
                        x, y);
                // TODO: supports path relative
                mMotionLayout.mScene.setKeyframe((View) view, position, "motion:percentX", fx);
                mMotionLayout.mScene.setKeyframe((View) view, position, "motion:percentY", fy);
                mMotionLayout.rebuildScene();
                mMotionLayout.evaluate(true);
                mMotionLayout.invalidate();
                return true;
            }
        }
        return false;
    }

    /**
     * @param view
     * @param debugMode
     *
     */
    public void setViewDebug(Object view, int debugMode) {
        if (!(view instanceof View)) {
            return;
        }

        MotionController motionController = mMotionLayout.mFrameArrayList.get(view);
        if (motionController != null) {
            motionController.setDrawPath(debugMode);
            mMotionLayout.invalidate();
        }
    }

    /**
     * This is a general access to systems in the  MotionLayout System
     * This provides a series of commands used by the designer to access needed logic
     * It is written this way to minimize the interface between the library and designer.
     * It allows the logic to be kept only in the library not replicated in the gui builder.
     * It also allows us to understand understand the version  of MotionLayout in use
     * commands
     * 0 return the version number
     * 1 Get the center point of the animation path of a view
     * 2 Get the location of the start end and key frames
     *
     * @param cmd        this provide the command needed
     * @param type       support argument for command
     * @param viewObject if this command references a view this provides access
     * @param in         this allows for an array of float to be the input to the system
     * @param inLength   this provides the length of the input
     * @param out        this provide the output array
     * @param outLength  the length of the output array
     * @return command dependent -1 is typically an error (do not understand)
     */
    public int designAccess(int cmd, String type, Object viewObject,
                            float[] in, int inLength, float[] out, int outLength) {
        View view = (View) viewObject;
        MotionController motionController = null;
        if (cmd != 0) {
            if (mMotionLayout.mScene == null) {
                return -1;
            }

            if (view != null) { // Can't find the view
                motionController = mMotionLayout.mFrameArrayList.get(view);
                if (motionController == null) {
                    return -1;
                }
            } else { // currently only cmd  == 0 does not require a motion view
                return -1;
            }

        }
        switch (cmd) {
            case 0: // version
                return 1;
            case 1: { // get View path

                int duration = mMotionLayout.mScene.getDuration();
                int frames = duration / 16;

                motionController.buildPath(out, frames);
                return frames;
            }
            case 2: { // get key frames

                int duration = mMotionLayout.mScene.getDuration();
                int frames = duration / 16;

                motionController.buildKeyFrames(out, null);
                return frames;
            }
            case 3: { // get Attribute

                int duration = mMotionLayout.mScene.getDuration();
                @SuppressWarnings("unused") int frames = duration / 16;

                return motionController.getAttributeValues(type, out, outLength);
            }

            default:
                return -1;

        }
    }

    // @TODO: add description

    /**
     *
     * @param type
     * @param target
     * @param position
     * @return
     */
    public Object getKeyframe(int type, int target, int position) {
        if (mMotionLayout.mScene == null) {
            return null;
        }
        return mMotionLayout.mScene.getKeyFrame(mMotionLayout.getContext(), type, target, position);
    }

    // @TODO: add description

    /**
     *
     * @param viewObject
     * @param x
     * @param y
     * @return
     */
    public Object getKeyframeAtLocation(Object viewObject, float x, float y) {
        View view = (View) viewObject;
        MotionController motionController = null;
        if (mMotionLayout.mScene == null) {
            return -1;
        }
        if (view != null) { // Can't find the view
            motionController = mMotionLayout.mFrameArrayList.get(view);
            if (motionController == null) {
                return null;
            }
        } else {
            return null;
        }
        ViewGroup viewGroup = ((ViewGroup) view.getParent());
        int layoutWidth = viewGroup.getWidth();
        int layoutHeight = viewGroup.getHeight();
        return motionController.getPositionKeyframe(layoutWidth, layoutHeight, x, y);
    }

    // @TODO: add description

    /**
     *
     * @param keyFrame
     * @param view
     * @param x
     * @param y
     * @param attribute
     * @param value
     * @return
     */
    public Boolean getPositionKeyframe(Object keyFrame,
                                       Object view,
                                       float x,
                                       float y,
                                       String[] attribute,
                                       float[] value) {
        if (keyFrame instanceof KeyPositionBase) {
            KeyPositionBase key = (KeyPositionBase) keyFrame;
            MotionController motionController = mMotionLayout.mFrameArrayList.get((View) view);
            motionController.positionKeyframe((View) view, key, x, y, attribute, value);
            mMotionLayout.rebuildScene();
            mMotionLayout.mInTransition = true;
            return true;
        }
        return false;
    }

    // @TODO: add description

    /**
     *
     * @param view
     * @param type
     * @param position
     * @return
     */
    public Object getKeyframe(Object view, int type, int position) {
        if (mMotionLayout.mScene == null) {
            return null;
        }
        int target = ((View) view).getId();
        return mMotionLayout.mScene.getKeyFrame(mMotionLayout.getContext(), type, target, position);
    }

    // @TODO: add description

    /**
     *
     * @param keyFrame
     * @param tag
     * @param value
     */
    public void setKeyframe(Object keyFrame, String tag, Object value) {
        if (keyFrame instanceof Key) {
            Key key = (Key) keyFrame;
            key.setValue(tag, value);
            mMotionLayout.rebuildScene();
            mMotionLayout.mInTransition = true;
        }
    }

    /**
     * Live setting of attributes on a view
     *
     * @param dpi              dpi used by the application
     * @param constraintSetId  ConstraintSet id
     * @param opaqueView       the Android View we operate on, passed as an Object
     * @param opaqueAttributes the list of attributes (hash<string,string>) we pass to the view
     */
    public void setAttributes(int dpi,
                              String constraintSetId,
                              Object opaqueView,
                              Object opaqueAttributes) {
        View view = (View) opaqueView;

        @SuppressWarnings("unchecked")
        HashMap<String, String> attributes = (opaqueAttributes instanceof HashMap) ?
                (HashMap<String, String>) opaqueAttributes : new HashMap<>();

        int rscId = mMotionLayout.lookUpConstraintId(constraintSetId);
        ConstraintSet set = mMotionLayout.mScene.getConstraintSet(rscId);

        if (DEBUG) {
            Log.v(TAG, "constraintSetId  = " + constraintSetId + "  " + rscId);
        }

        if (set == null) {
            return;
        }

        set.clear(view.getId());

        setDimensions(dpi, set, view, attributes, HORIZONTAL);
        setDimensions(dpi, set, view, attributes, VERTICAL);

        connect(dpi, set, view, attributes, START, START);
        connect(dpi, set, view, attributes, START, END);
        connect(dpi, set, view, attributes, END, END);
        connect(dpi, set, view, attributes, END, START);
        connect(dpi, set, view, attributes, LEFT, LEFT);
        connect(dpi, set, view, attributes, LEFT, RIGHT);
        connect(dpi, set, view, attributes, RIGHT, RIGHT);
        connect(dpi, set, view, attributes, RIGHT, LEFT);
        connect(dpi, set, view, attributes, TOP, TOP);
        connect(dpi, set, view, attributes, TOP, BOTTOM);
        connect(dpi, set, view, attributes, BOTTOM, TOP);
        connect(dpi, set, view, attributes, BOTTOM, BOTTOM);
        connect(dpi, set, view, attributes, BASELINE, BASELINE);

        setBias(set, view, attributes, HORIZONTAL);
        setBias(set, view, attributes, VERTICAL);

        setAbsolutePositions(dpi, set, view, attributes);

        mMotionLayout.updateState(rscId, set);
        mMotionLayout.requestLayout();
    }

    // @TODO: add description

    /**
     *
     * @param set
     */
    public void dumpConstraintSet(String set) {
        if (mMotionLayout.mScene == null) {
            mMotionLayout.mScene = mSceneCache;
        }
        int setId = mMotionLayout.lookUpConstraintId(set);
        System.out.println(" dumping  " + set + " (" + setId + ")");
        try {
            mMotionLayout.mScene.getConstraintSet(setId).dump(mMotionLayout.mScene);
        } catch (Exception ex) {
            Log.e(TAG, "Error while dumping: " + set + " (" + setId + ")", ex);
        }
    }
}