1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.wear.widget;
18
19import android.content.Context;
20import android.graphics.Path;
21import android.graphics.PathMeasure;
22import android.view.View;
23
24import androidx.annotation.VisibleForTesting;
25import androidx.recyclerview.widget.RecyclerView;
26import androidx.wear.R;
27
28/**
29 * An implementation of the {@link WearableLinearLayoutManager.LayoutCallback} aligning the children
30 * of the associated {@link WearableRecyclerView} along a pre-defined vertical curve.
31 */
32public class CurvingLayoutCallback extends WearableLinearLayoutManager.LayoutCallback {
33    private static final float EPSILON = 0.001f;
34
35    private final Path mCurvePath;
36    private final PathMeasure mPathMeasure;
37    private int mCurvePathHeight;
38    private int mXCurveOffset;
39    private float mPathLength;
40    private float mCurveBottom;
41    private float mCurveTop;
42    private float mLineGradient;
43    private final float[] mPathPoints = new float[2];
44    private final float[] mPathTangent = new float[2];
45    private final float[] mAnchorOffsetXY = new float[2];
46
47    private RecyclerView mParentView;
48    private boolean mIsScreenRound;
49    private int mLayoutWidth;
50    private int mLayoutHeight;
51
52    public CurvingLayoutCallback(Context context) {
53        mCurvePath = new Path();
54        mPathMeasure = new PathMeasure();
55        mIsScreenRound = context.getResources().getConfiguration().isScreenRound();
56        mXCurveOffset = context.getResources().getDimensionPixelSize(
57                R.dimen.ws_wrv_curve_default_x_offset);
58    }
59
60    @Override
61    public void onLayoutFinished(View child, RecyclerView parent) {
62        if (mParentView != parent || (mParentView != null && (
63                mParentView.getWidth() != parent.getWidth()
64                        || mParentView.getHeight() != parent.getHeight()))) {
65            mParentView = parent;
66            mLayoutWidth = mParentView.getWidth();
67            mLayoutHeight = mParentView.getHeight();
68        }
69        if (mIsScreenRound) {
70            maybeSetUpCircularInitialLayout(mLayoutWidth, mLayoutHeight);
71            mAnchorOffsetXY[0] = mXCurveOffset;
72            mAnchorOffsetXY[1] = child.getHeight() / 2.0f;
73            adjustAnchorOffsetXY(child, mAnchorOffsetXY);
74            float minCenter = -(float) child.getHeight() / 2;
75            float maxCenter = mLayoutHeight + (float) child.getHeight() / 2;
76            float range = maxCenter - minCenter;
77            float verticalAnchor = (float) child.getTop() + mAnchorOffsetXY[1];
78            float mYScrollProgress = (verticalAnchor + Math.abs(minCenter)) / range;
79
80            mPathMeasure.getPosTan(mYScrollProgress * mPathLength, mPathPoints, mPathTangent);
81
82            boolean topClusterRisk =
83                    Math.abs(mPathPoints[1] - mCurveBottom) < EPSILON
84                            && minCenter < mPathPoints[1];
85            boolean bottomClusterRisk =
86                    Math.abs(mPathPoints[1] - mCurveTop) < EPSILON
87                            && maxCenter > mPathPoints[1];
88            // Continue offsetting the child along the straight-line part of the curve, if it
89            // has not gone off the screen when it reached the end of the original curve.
90            if (topClusterRisk || bottomClusterRisk) {
91                mPathPoints[1] = verticalAnchor;
92                mPathPoints[0] = (Math.abs(verticalAnchor) * mLineGradient);
93            }
94
95            // Offset the View to match the provided anchor point.
96            int newLeft = (int) (mPathPoints[0] - mAnchorOffsetXY[0]);
97            child.offsetLeftAndRight(newLeft - child.getLeft());
98            float verticalTranslation = mPathPoints[1] - verticalAnchor;
99            child.setTranslationY(verticalTranslation);
100        } else {
101            child.setTranslationY(0);
102        }
103    }
104
105    /**
106     * Override this method if you wish to adjust the anchor coordinates for each child view
107     * during a layout pass. In the override set the new desired anchor coordinates in
108     * the provided array. The coordinates should be provided in relation to the child view.
109     *
110     * @param child          The child view to which the anchor coordinates will apply.
111     * @param anchorOffsetXY The anchor coordinates for the provided child view, by default set
112     *                       to a pre-defined constant on the horizontal axis and half of the
113     *                       child height on the vertical axis (vertical center).
114     */
115    public void adjustAnchorOffsetXY(View child, float[] anchorOffsetXY) {
116        return;
117    }
118
119    @VisibleForTesting
120    void setRound(boolean isScreenRound) {
121        mIsScreenRound = isScreenRound;
122    }
123
124    @VisibleForTesting
125    void setOffset(int offset) {
126        mXCurveOffset = offset;
127    }
128
129    /** Set up the initial layout for round screens. */
130    private void maybeSetUpCircularInitialLayout(int width, int height) {
131        // The values in this function are custom to the curve we use.
132        if (mCurvePathHeight != height) {
133            mCurvePathHeight = height;
134            mCurveBottom = -0.048f * height;
135            mCurveTop = 1.048f * height;
136            mLineGradient = 0.5f / 0.048f;
137            mCurvePath.reset();
138            mCurvePath.moveTo(0.5f * width, mCurveBottom);
139            mCurvePath.lineTo(0.34f * width, 0.075f * height);
140            mCurvePath.cubicTo(
141                    0.22f * width, 0.17f * height, 0.13f * width, 0.32f * height, 0.13f * width,
142                    height / 2);
143            mCurvePath.cubicTo(
144                    0.13f * width,
145                    0.68f * height,
146                    0.22f * width,
147                    0.83f * height,
148                    0.34f * width,
149                    0.925f * height);
150            mCurvePath.lineTo(width / 2, mCurveTop);
151            mPathMeasure.setPath(mCurvePath, false);
152            mPathLength = mPathMeasure.getLength();
153        }
154    }
155}
156