CircularFlow.java

/*
 * Copyright (C) 2021 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.helper.widget;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.R;
import androidx.constraintlayout.widget.VirtualLayout;
import java.util.Arrays;

/**
 *
 * CircularFlow virtual layout.
 *
 * Allows positioning of referenced widgets circular.
 *
 * The elements referenced are indicated via constraint_referenced_ids, as with other ContraintHelper implementations.
 *
 * XML attributes that are needed:
 * <ul>
 *     <li>constraint_referenced_ids = "view2, view3, view4,view5,view6"</li>. It receives id's of the views that will add the references.
 *     <li>circularflow_viewCenter = "view1"</li>. It receives the id of the view of the center where the views received in constraint_referenced_ids will be referenced.
 *     <li>circularflow_angles = "45,90,135,180,225"</li>. Receive the angles that you will assign to each view.
 *     <li>circularflow_radiusInDP = "90,100,110,120,130"</li>. Receive the radios in DP that you will assign to each view.
 * </ul>
 *
 * Example in XML:
 * <androidx.constraintlayout.helper.widget.CircularFlow
 *         android:id="@+id/circularFlow"
 *         android:layout_width="match_parent"
 *         android:layout_height="match_parent"
 *         app:circularflow_angles="0,40,80,120"
 *         app:circularflow_radiusInDP="90,100,110,120"
 *         app:circularflow_viewCenter="@+id/view1"
 *         app:constraint_referenced_ids="view2,view3,view4,view5" />
 *
 * DEFAULT radius - If you add a view and don't set its radius, the default value will be 0.
 * DEFAULT angles - If you add a view and don't set its angle, the default value will be 0.
 *
 * Recommendation - always set radius and angle for all views in <i>constraint_referenced_ids</i>
 *
 * */

public class CircularFlow extends VirtualLayout {
    private static final String TAG = "CircularFlow";
    ConstraintLayout mContainer;
    int mViewCenter;
    private static int DEFAULT_RADIUS = 0;
    private static float DEFAULT_ANGLE = 0F;
    /**
     * @hide
     */
    private float[] mAngles;

    /**
     * @hide
     */
    private int[] mRadius;

    /**
     * @hide
     */
    private int mCountRadius;

    /**
     * @hide
     */
    private int mCountAngle;

    /**
     * @hide
     */
    private String mReferenceAngles;

    /**
     * @hide
     */
    private String mReferenceRadius;

    /**
     * @hide
     */
    private Float mReferenceDefaultAngle;

    /**
     * @hide
     */
    private Integer mReferenceDefaultRadius;


    public CircularFlow(Context context) {
        super(context);
    }

    public CircularFlow(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CircularFlow(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public int[] getRadius() {
        return Arrays.copyOf(mRadius, mCountRadius);
    }


    public float[] getAngles() {
        return Arrays.copyOf(mAngles, mCountAngle);
    }


    @Override
    protected void init(AttributeSet attrs) {
        super.init(attrs);
        if (attrs != null) {
            TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ConstraintLayout_Layout);
            final int N = a.getIndexCount();

            for (int i = 0; i < N; i++) {
                int attr = a.getIndex(i);
                if (attr == R.styleable.ConstraintLayout_Layout_circularflow_viewCenter) {
                    mViewCenter = a.getResourceId(attr, 0);
                } else if (attr == R.styleable.ConstraintLayout_Layout_circularflow_angles) {
                    mReferenceAngles = a.getString(attr);
                    setAngles(mReferenceAngles);
                } else if (attr == R.styleable.ConstraintLayout_Layout_circularflow_radiusInDP) {
                    mReferenceRadius = a.getString(attr);
                    setRadius(mReferenceRadius);
                } else if (attr == R.styleable.ConstraintLayout_Layout_circularflow_defaultAngle) {
                    mReferenceDefaultAngle = a.getFloat(attr, DEFAULT_ANGLE);
                    setDefaultAngle(mReferenceDefaultAngle);
                } else if (attr == R.styleable.ConstraintLayout_Layout_circularflow_defaultRadius) {
                    mReferenceDefaultRadius = a.getDimensionPixelSize(attr, DEFAULT_RADIUS);
                    setDefaultRadius(mReferenceDefaultRadius);
                }
            }
            a.recycle();
        }
    }

    @Override
    public void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (mReferenceAngles != null) {
            mAngles = new float[1];
            setAngles(mReferenceAngles);
        }
        if (mReferenceRadius != null) {
            mRadius = new int[1];
            setRadius(mReferenceRadius);
        }
        if (mReferenceDefaultAngle != null) {
            setDefaultAngle(mReferenceDefaultAngle);
        }
        if (mReferenceDefaultRadius != null) {
            setDefaultRadius(mReferenceDefaultRadius);
        }
        anchorReferences();
    }

    private void anchorReferences() {
        mContainer = (ConstraintLayout) getParent();
        for (int i = 0; i < mCount; i++) {
            View view = mContainer.getViewById(mIds[i]);
            if (view == null) {
                continue;
            }
            int radius = DEFAULT_RADIUS;
            float angle = DEFAULT_ANGLE;

            if (mRadius != null && i < mRadius.length) {
                radius = mRadius[i];
            } else if (mReferenceDefaultRadius != null && mReferenceDefaultRadius != -1) {
                mCountRadius++;
                if (mRadius == null) {
                    mRadius = new int[1];
                }
                mRadius = getRadius();
                mRadius[mCountRadius - 1] = radius;
            } else {
                Log.e("CircularFlow", "Added radius to view with id: " + mMap.get(view.getId()));
            }

            if (mAngles != null && i < mAngles.length) {
                angle = mAngles[i];
            } else if (mReferenceDefaultAngle != null && mReferenceDefaultAngle != -1) {
                mCountAngle++;
                if (mAngles == null) {
                    mAngles = new float[1];
                }
                mAngles = getAngles();
                mAngles[mCountAngle - 1] = angle;
            } else {
                Log.e("CircularFlow", "Added angle to view with id: " + mMap.get(view.getId()));
            }
            ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) view.getLayoutParams();
            params.circleAngle = angle;
            params.circleConstraint = mViewCenter;
            params.circleRadius = radius;
            view.setLayoutParams(params);
        }
        applyLayoutFeatures();
    }

    /**
     * Add a view to the CircularFlow. The referenced view need to be a child of the container parent.
     * The view also need to have its id set in order to be added.
     * The views previous need to have its radius and angle set in order to be added correctly a new view.
     * @param view
     * @param radius
     * @param angle
     * @return
     */
    public void addViewToCircularFlow(View view, int radius, float angle) {
        if (containsId(view.getId())){
            return;
        }
        addView(view);
        mCountAngle++;
        mAngles = getAngles();
        mAngles[mCountAngle - 1] = angle;
        mCountRadius++;
        mRadius = getRadius();
        mRadius[mCountRadius - 1] = (int) (radius * myContext.getResources().getDisplayMetrics().density);
        anchorReferences();
    }

    /**
     * Update radius from a view in CircularFlow. The referenced view need to be a child of the container parent.
     * The view also need to have its id set in order to be added.
     * @param view
     * @param radius
     * @return
     */
    public void updateRadius(View view, int radius) {
        if (!isUpdatable(view)) {
            Log.e("CircularFlow", "It was not possible to update radius to view with id: " + view.getId());
            return;
        }
        int indexView = indexFromId(view.getId());
        if (indexView > mRadius.length) {
            return;
        }
        mRadius = getRadius();
        mRadius[indexView] = (int) (radius * myContext.getResources().getDisplayMetrics().density);
        anchorReferences();
    }

    /**
     * Update angle from a view in CircularFlow. The referenced view need to be a child of the container parent.
     * The view also need to have its id set in order to be added.
     * @param view
     * @param angle
     * @return
     */
    public void updateAngle(View view, float angle) {
        if (!isUpdatable(view)) {
            Log.e("CircularFlow", "It was not possible to update angle to view with id: " + view.getId());
            return;
        }
        int indexView = indexFromId(view.getId());
        if (indexView > mAngles.length) {
            return;
        }
        mAngles = getAngles();
        mAngles[indexView] = angle;
        anchorReferences();
    }

    /**
     * Update angle and radius from a view in CircularFlow. The referenced view need to be a child of the container parent.
     * The view also need to have its id set in order to be added.
     * @param view
     * @param radius
     * @param angle
     * @return
     */
    public void updateReference(View view, int radius, float angle) {
        if (!isUpdatable(view)) {
            Log.e("CircularFlow", "It was not possible to update radius and angle to view with id: " + view.getId());
            return;
        }
        int indexView = indexFromId(view.getId());
        if (getAngles().length  > indexView) {
            mAngles = getAngles();
            mAngles[indexView] = angle;
        }
        if (getRadius().length  > indexView) {
            mRadius = getRadius();
            mRadius[indexView] = (int) (radius * myContext.getResources().getDisplayMetrics().density);
        }
        anchorReferences();
    }

    /**
     * Set default Angle for CircularFlow.
     *
     * @param angle
     * @return
     */
    public void setDefaultAngle(float angle) {
        DEFAULT_ANGLE = angle;
    }

    /**
     * Set default Radius for CircularFlow.
     *
     * @param radius
     * @return
     */
    public void setDefaultRadius(int radius) {
        DEFAULT_RADIUS = radius;
    }

    @Override
    public int removeView(View view) {
        int index = super.removeView(view);
        if (index == -1) {
            return index;
        }
        ConstraintSet c = new ConstraintSet();
        c.clone(mContainer);
        c.clear(view.getId(), ConstraintSet.CIRCLE_REFERENCE);
        c.applyTo(mContainer);

        if (index < mAngles.length) {
            mAngles = removeAngle(mAngles, index);
            mCountAngle--;
        }
        if (index < mRadius.length) {
            mRadius = removeRadius(mRadius, index);
            mCountRadius--;
        }
        anchorReferences();
        return index;
    }

    /**
     * @hide
     */
    private float[] removeAngle(float[] angles, int index) {
        if (angles == null
                || index < 0
                || index >= mCountAngle) {
            return angles;
        }

        return removeElementFromArray(angles, index);
    }

    /**
     * @hide
     */
    private int[] removeRadius(int[] radius, int index) {
        if (radius == null
                || index < 0
                || index >= mCountRadius) {
            return radius;
        }

        return removeElementFromArray(radius, index);
    }

    /**
     * @hide
     */
    private void setAngles(String idList) {
        if (idList == null) {
            return;
        }
        int begin = 0;
        mCountAngle = 0;
        while (true) {
            int end = idList.indexOf(',', begin);
            if (end == -1) {
                addAngle(idList.substring(begin).trim());
                break;
            }
            addAngle(idList.substring(begin, end).trim());
            begin = end + 1;
        }
    }

    /**
     * @hide
     */
    private void setRadius(String idList) {
        if (idList == null) {
            return;
        }
        int begin = 0;
        mCountRadius = 0;
        while (true) {
            int end = idList.indexOf(',', begin);
            if (end == -1) {
                addRadius(idList.substring(begin).trim());
                break;
            }
            addRadius(idList.substring(begin, end).trim());
            begin = end + 1;
        }
    }

    /**
     * @hide
     */
    private void addAngle(String angleString) {
        if (angleString == null || angleString.length() == 0) {
            return;
        }
        if (myContext == null) {
            return;
        }
        if (mAngles == null) {
            return;
        }

        if (mCountAngle + 1 > mAngles.length) {
            mAngles = Arrays.copyOf(mAngles, mAngles.length + 1);
        }
        mAngles[mCountAngle] = Integer.parseInt(angleString);
        mCountAngle++;
    }

    /**
     * @hide
     */
    private void addRadius(String radiusString) {
        if (radiusString == null || radiusString.length() == 0) {
            return;
        }
        if (myContext == null) {
            return;
        }
        if (mRadius == null) {
            return;
        }

        if (mCountRadius + 1 > mRadius.length) {
            mRadius = Arrays.copyOf(mRadius, mRadius.length + 1);
        }

        mRadius[mCountRadius] = (int) (Integer.parseInt(radiusString) * myContext.getResources().getDisplayMetrics().density);
        mCountRadius++;
    }

    public static int[] removeElementFromArray(int[] array, int index) {
        int[] newArray = new int[array.length - 1];

        for (int i = 0, k = 0; i < array.length; i++) {
            if (i == index) {
                continue;
            }
            newArray[k++] = array[i];
        }
        return newArray;
    }

    public static float[] removeElementFromArray(float[] array, int index) {
        float[] newArray = new float[array.length - 1];

        for (int i = 0, k = 0; i < array.length; i++) {
            if (i == index) {
                continue;
            }
            newArray[k++] = array[i];
        }
        return newArray;
    }

    public boolean isUpdatable(View view) {
        if (!containsId(view.getId())){
            return false;
        }
        int indexView = indexFromId(view.getId());
        return indexView != -1;
    }
}