CurvingLayoutCallback.java

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

import android.content.Context;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.view.View;

import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;
import androidx.wear.R;

/**
 * An implementation of the {@link WearableLinearLayoutManager.LayoutCallback} aligning the children
 * of the associated {@link WearableRecyclerView} along a pre-defined vertical curve.
 */
public class CurvingLayoutCallback extends WearableLinearLayoutManager.LayoutCallback {
    private static final float EPSILON = 0.001f;

    private final Path mCurvePath;
    private final PathMeasure mPathMeasure;
    private int mCurvePathHeight;
    private int mXCurveOffset;
    private float mPathLength;
    private float mCurveBottom;
    private float mCurveTop;
    private float mLineGradient;
    private final float[] mPathPoints = new float[2];
    private final float[] mPathTangent = new float[2];
    private final float[] mAnchorOffsetXY = new float[2];

    private RecyclerView mParentView;
    private boolean mIsScreenRound;
    private int mLayoutWidth;
    private int mLayoutHeight;

    public CurvingLayoutCallback(Context context) {
        mCurvePath = new Path();
        mPathMeasure = new PathMeasure();
        mIsScreenRound = context.getResources().getConfiguration().isScreenRound();
        mXCurveOffset = context.getResources().getDimensionPixelSize(
                R.dimen.ws_wrv_curve_default_x_offset);
    }

    @Override
    public void onLayoutFinished(View child, RecyclerView parent) {
        if (mParentView != parent || (mParentView != null && (
                mParentView.getWidth() != parent.getWidth()
                        || mParentView.getHeight() != parent.getHeight()))) {
            mParentView = parent;
            mLayoutWidth = mParentView.getWidth();
            mLayoutHeight = mParentView.getHeight();
        }
        if (mIsScreenRound) {
            maybeSetUpCircularInitialLayout(mLayoutWidth, mLayoutHeight);
            mAnchorOffsetXY[0] = mXCurveOffset;
            mAnchorOffsetXY[1] = child.getHeight() / 2.0f;
            adjustAnchorOffsetXY(child, mAnchorOffsetXY);
            float minCenter = -(float) child.getHeight() / 2;
            float maxCenter = mLayoutHeight + (float) child.getHeight() / 2;
            float range = maxCenter - minCenter;
            float verticalAnchor = (float) child.getTop() + mAnchorOffsetXY[1];
            float mYScrollProgress = (verticalAnchor + Math.abs(minCenter)) / range;

            mPathMeasure.getPosTan(mYScrollProgress * mPathLength, mPathPoints, mPathTangent);

            boolean topClusterRisk =
                    Math.abs(mPathPoints[1] - mCurveBottom) < EPSILON
                            && minCenter < mPathPoints[1];
            boolean bottomClusterRisk =
                    Math.abs(mPathPoints[1] - mCurveTop) < EPSILON
                            && maxCenter > mPathPoints[1];
            // Continue offsetting the child along the straight-line part of the curve, if it
            // has not gone off the screen when it reached the end of the original curve.
            if (topClusterRisk || bottomClusterRisk) {
                mPathPoints[1] = verticalAnchor;
                mPathPoints[0] = (Math.abs(verticalAnchor) * mLineGradient);
            }

            // Offset the View to match the provided anchor point.
            int newLeft = (int) (mPathPoints[0] - mAnchorOffsetXY[0]);
            child.offsetLeftAndRight(newLeft - child.getLeft());
            float verticalTranslation = mPathPoints[1] - verticalAnchor;
            child.setTranslationY(verticalTranslation);
        } else {
            child.setTranslationY(0);
        }
    }

    /**
     * Override this method if you wish to adjust the anchor coordinates for each child view
     * during a layout pass. In the override set the new desired anchor coordinates in
     * the provided array. The coordinates should be provided in relation to the child view.
     *
     * @param child          The child view to which the anchor coordinates will apply.
     * @param anchorOffsetXY The anchor coordinates for the provided child view, by default set
     *                       to a pre-defined constant on the horizontal axis and half of the
     *                       child height on the vertical axis (vertical center).
     */
    public void adjustAnchorOffsetXY(View child, float[] anchorOffsetXY) {
        return;
    }

    @VisibleForTesting
    void setRound(boolean isScreenRound) {
        mIsScreenRound = isScreenRound;
    }

    @VisibleForTesting
    void setOffset(int offset) {
        mXCurveOffset = offset;
    }

    /** Set up the initial layout for round screens. */
    private void maybeSetUpCircularInitialLayout(int width, int height) {
        // The values in this function are custom to the curve we use.
        if (mCurvePathHeight != height) {
            mCurvePathHeight = height;
            mCurveBottom = -0.048f * height;
            mCurveTop = 1.048f * height;
            mLineGradient = 0.5f / 0.048f;
            mCurvePath.reset();
            mCurvePath.moveTo(0.5f * width, mCurveBottom);
            mCurvePath.lineTo(0.34f * width, 0.075f * height);
            mCurvePath.cubicTo(
                    0.22f * width, 0.17f * height, 0.13f * width, 0.32f * height, 0.13f * width,
                    height / 2);
            mCurvePath.cubicTo(
                    0.13f * width,
                    0.68f * height,
                    0.22f * width,
                    0.83f * height,
                    0.34f * width,
                    0.925f * height);
            mCurvePath.lineTo(width / 2, mCurveTop);
            mPathMeasure.setPath(mCurvePath, false);
            mPathLength = mPathMeasure.getLength();
        }
    }
}