Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a5368ecc authored by Garvit Narang's avatar Garvit Narang Committed by Android (Google) Code Review
Browse files

Merge "Modify scrollbar to include gap between thumb and track" into main

parents 5ade4db7 d154de1a
Loading
Loading
Loading
Loading
+191 −58
Original line number Diff line number Diff line
@@ -16,17 +16,26 @@

package android.view;

import static android.util.MathUtils.acos;

import static java.lang.Math.sin;

import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.SystemProperties;
import android.util.DisplayMetrics;
import android.view.flags.Flags;

/**
 * Helper class for drawing round scroll bars on round Wear devices.
 *
 * @hide
 */
class RoundScrollbarRenderer {
public class RoundScrollbarRenderer {
    private static final String BLUECHIP_ENABLED_SYSPROP = "persist.cw_build.bluechip.enabled";
    // The range of the scrollbar position represented as an angle in degrees.
    private static final float SCROLLBAR_ANGLE_RANGE = 28.8f;
    private static final float MAX_SCROLLBAR_ANGLE_SWIPE = 26.3f; // 90%
@@ -45,12 +54,15 @@ class RoundScrollbarRenderer {
    private final Paint mTrackPaint = new Paint();
    private final RectF mRect = new RectF();
    private final View mParent;
    private final int mMaskThickness;
    private final float mInset;

    private float mPreviousMaxScroll = 0;
    private float mMaxScrollDiff = 0;
    private float mPreviousCurrentScroll = 0;
    private float mCurrentScrollDiff = 0;
    private float mThumbStrokeWidthAsDegrees = 0;
    private boolean mDrawToLeft;
    private boolean mUseRefactoredRoundScrollbar;

    public RoundScrollbarRenderer(View parent) {
        // Paints for the round scrollbar.
@@ -69,29 +81,36 @@ class RoundScrollbarRenderer {
        // Fetch the resource indicating the thickness of CircularDisplayMask, rounding in the same
        // way WindowManagerService.showCircularMask does. The scroll bar is inset by this amount so
        // that it doesn't get clipped.
        mMaskThickness = parent.getContext().getResources().getDimensionPixelSize(
        int maskThickness =
                parent.getContext()
                        .getResources()
                        .getDimensionPixelSize(
                                com.android.internal.R.dimen.circular_display_mask_thickness);
    }

    public void drawRoundScrollbars(Canvas canvas, float alpha, Rect bounds, boolean drawToLeft) {
        if (alpha == 0) {
            return;
        float thumbWidth = dpToPx(THUMB_WIDTH_DP);
        mThumbPaint.setStrokeWidth(thumbWidth);
        mTrackPaint.setStrokeWidth(thumbWidth);
        mInset = thumbWidth / 2 + maskThickness;

        mUseRefactoredRoundScrollbar =
                Flags.useRefactoredRoundScrollbar()
                        && SystemProperties.getBoolean(BLUECHIP_ENABLED_SYSPROP, false);
    }
        // Get information about the current scroll state of the parent view.
        float maxScroll = mParent.computeVerticalScrollRange();
        float scrollExtent = mParent.computeVerticalScrollExtent();
        float newScroll = mParent.computeVerticalScrollOffset();

    private float computeScrollExtent(float scrollExtent, float maxScroll) {
        if (scrollExtent <= 0) {
            if (!mParent.canScrollVertically(1) && !mParent.canScrollVertically(-1)) {
                return;
                return -1f;
            } else {
                scrollExtent = 0;
                return 0f;
            }
        } else if (maxScroll <= scrollExtent) {
            return;
            return -1f;
        }
        return scrollExtent;
    }

    private void resizeGradually(float maxScroll, float newScroll) {
        // Make changes to the VerticalScrollRange happen gradually
        if (Math.abs(maxScroll - mPreviousMaxScroll) > RESIZING_THRESHOLD_PX
                && mPreviousMaxScroll != 0) {
@@ -106,49 +125,79 @@ class RoundScrollbarRenderer {
                || Math.abs(mCurrentScrollDiff) > RESIZING_THRESHOLD_PX) {
            mMaxScrollDiff *= RESIZING_RATE;
            mCurrentScrollDiff *= RESIZING_RATE;

            maxScroll -= mMaxScrollDiff;
            newScroll -= mCurrentScrollDiff;
        } else {
            mMaxScrollDiff = 0;
            mCurrentScrollDiff = 0;
        }
    }

        float currentScroll = Math.max(0, newScroll);
        float linearThumbLength = scrollExtent;
        float thumbWidth = dpToPx(THUMB_WIDTH_DP);
        mThumbPaint.setStrokeWidth(thumbWidth);
        mTrackPaint.setStrokeWidth(thumbWidth);
    public void drawRoundScrollbars(Canvas canvas, float alpha, Rect bounds, boolean drawToLeft) {
        if (alpha == 0) {
            return;
        }
        // Get information about the current scroll state of the parent view.
        float maxScroll = mParent.computeVerticalScrollRange();
        float scrollExtent = mParent.computeVerticalScrollExtent();
        float newScroll = mParent.computeVerticalScrollOffset();

        setThumbColor(applyAlpha(DEFAULT_THUMB_COLOR, alpha));
        setTrackColor(applyAlpha(DEFAULT_TRACK_COLOR, alpha));
        scrollExtent = computeScrollExtent(scrollExtent, maxScroll);
        if (scrollExtent < 0f) {
            return;
        }

        // Normalize the sweep angle for the scroll bar.
        float sweepAngle = (linearThumbLength / maxScroll) * SCROLLBAR_ANGLE_RANGE;
        sweepAngle = clamp(sweepAngle, MIN_SCROLLBAR_ANGLE_SWIPE, MAX_SCROLLBAR_ANGLE_SWIPE);
        // Normalize the start angle so that it falls on the track.
        float startAngle = (currentScroll * (SCROLLBAR_ANGLE_RANGE - sweepAngle))
                / (maxScroll - linearThumbLength) - SCROLLBAR_ANGLE_RANGE / 2f;
        startAngle = clamp(startAngle, -SCROLLBAR_ANGLE_RANGE / 2f,
                SCROLLBAR_ANGLE_RANGE / 2f - sweepAngle);
        // Make changes to the VerticalScrollRange happen gradually
        resizeGradually(maxScroll, newScroll);
        maxScroll -= mMaxScrollDiff;
        newScroll -= mCurrentScrollDiff;

        // Draw the track and the thumb.
        float inset = thumbWidth / 2 + mMaskThickness;
        mRect.set(
                bounds.left + inset,
                bounds.top + inset,
                bounds.right - inset,
                bounds.bottom - inset);

        if (drawToLeft) {
            canvas.drawArc(mRect, 180 + SCROLLBAR_ANGLE_RANGE / 2f, -SCROLLBAR_ANGLE_RANGE, false,
                    mTrackPaint);
            canvas.drawArc(mRect, 180 - startAngle, -sweepAngle, false, mThumbPaint);
        applyThumbColor(alpha);

        float sweepAngle = computeSweepAngle(scrollExtent, maxScroll);
        float startAngle =
                computeStartAngle(Math.max(0, newScroll), sweepAngle, maxScroll, scrollExtent);

        updateBounds(bounds);

        mDrawToLeft = drawToLeft;
        drawRoundScrollbars(canvas, startAngle, sweepAngle, alpha);
    }

    private void drawRoundScrollbars(
            Canvas canvas, float startAngle, float sweepAngle, float alpha) {
        if (mUseRefactoredRoundScrollbar) {
            draw(canvas, startAngle, sweepAngle, alpha);
        } else {
            canvas.drawArc(mRect, -SCROLLBAR_ANGLE_RANGE / 2f, SCROLLBAR_ANGLE_RANGE, false,
                    mTrackPaint);
            canvas.drawArc(mRect, startAngle, sweepAngle, false, mThumbPaint);
            applyTrackColor(alpha);
            drawArc(canvas, -SCROLLBAR_ANGLE_RANGE / 2f, SCROLLBAR_ANGLE_RANGE, mTrackPaint);
            drawArc(canvas, startAngle, sweepAngle, mThumbPaint);
        }
    }

    /** Returns true if horizontal bounds are updated */
    private void updateBounds(Rect bounds) {
        mRect.set(
                bounds.left + mInset,
                bounds.top + mInset,
                bounds.right - mInset,
                bounds.bottom - mInset);
        mThumbStrokeWidthAsDegrees =
                getVertexAngle((mRect.right - mRect.left) / 2f, mThumbPaint.getStrokeWidth() / 2f);
    }

    private float computeSweepAngle(float scrollExtent, float maxScroll) {
        // Normalize the sweep angle for the scroll bar.
        float sweepAngle = (scrollExtent / maxScroll) * SCROLLBAR_ANGLE_RANGE;
        return clamp(sweepAngle, MIN_SCROLLBAR_ANGLE_SWIPE, MAX_SCROLLBAR_ANGLE_SWIPE);
    }

    private float computeStartAngle(
            float currentScroll, float sweepAngle, float maxScroll, float scrollExtent) {
        // Normalize the start angle so that it falls on the track.
        float startAngle =
                (currentScroll * (SCROLLBAR_ANGLE_RANGE - sweepAngle)) / (maxScroll - scrollExtent)
                        - SCROLLBAR_ANGLE_RANGE / 2f;
        return clamp(
                startAngle, -SCROLLBAR_ANGLE_RANGE / 2f, SCROLLBAR_ANGLE_RANGE / 2f - sweepAngle);
    }

    void getRoundVerticalScrollBarBounds(Rect bounds) {
@@ -164,10 +213,8 @@ class RoundScrollbarRenderer {
    private static float clamp(float val, float min, float max) {
        if (val < min) {
            return min;
        } else if (val > max) {
            return max;
        } else {
            return val;
            return Math.min(val, max);
        }
    }

@@ -176,15 +223,17 @@ class RoundScrollbarRenderer {
        return Color.argb(alphaByte, Color.red(color), Color.green(color), Color.blue(color));
    }

    private void setThumbColor(int thumbColor) {
        if (mThumbPaint.getColor() != thumbColor) {
            mThumbPaint.setColor(thumbColor);
    private void applyThumbColor(float alpha) {
        int color = applyAlpha(DEFAULT_THUMB_COLOR, alpha);
        if (mThumbPaint.getColor() != color) {
            mThumbPaint.setColor(color);
        }
    }

    private void setTrackColor(int trackColor) {
        if (mTrackPaint.getColor() != trackColor) {
            mTrackPaint.setColor(trackColor);
    private void applyTrackColor(float alpha) {
        int color = applyAlpha(DEFAULT_TRACK_COLOR, alpha);
        if (mTrackPaint.getColor() != color) {
            mTrackPaint.setColor(color);
        }
    }

@@ -192,4 +241,88 @@ class RoundScrollbarRenderer {
        return dp * ((float) mParent.getContext().getResources().getDisplayMetrics().densityDpi)
                / DisplayMetrics.DENSITY_DEFAULT;
    }

    private static float getVertexAngle(float edge, float base) {
        float edgeSquare = edge * edge * 2;
        float baseSquare = base * base;
        float gapInRadians = acos(((edgeSquare - baseSquare) / edgeSquare));
        return (float) Math.toDegrees(gapInRadians);
    }

    private static float getKiteEdge(float knownEdge, float angleBetweenKnownEdgesInDegrees) {
        return (float) (2 * knownEdge * sin(Math.toRadians(angleBetweenKnownEdgesInDegrees / 2)));
    }

    private void draw(Canvas canvas, float thumbStartAngle, float thumbSweepAngle, float alpha) {
        // Draws the top arc
        drawTrack(
                canvas,
                // The highest point of the top track on a vertical scale. Here the thumb width is
                // reduced to account for the arc formed by ROUND stroke style
                -SCROLLBAR_ANGLE_RANGE / 2f - mThumbStrokeWidthAsDegrees,
                // The lowest point of the top track on a vertical scale. Here the thumb width is
                // reduced twice to (a) account for the arc formed by ROUND stroke style (b) gap
                // between thumb and top track
                thumbStartAngle - mThumbStrokeWidthAsDegrees * 2,
                alpha);
        // Draws the thumb
        drawArc(canvas, thumbStartAngle, thumbSweepAngle, mThumbPaint);
        // Draws the bottom arc
        drawTrack(
                canvas,
                // The highest point of the bottom track on a vertical scale. Here the thumb width
                // is added twice to (a) account for the arc formed by ROUND stroke style (b) gap
                // between thumb and bottom track
                (thumbStartAngle + thumbSweepAngle) + mThumbStrokeWidthAsDegrees * 2,
                // The lowest point of the top track on a vertical scale. Here the thumb width is
                // added to account for the arc formed by ROUND stroke style
                SCROLLBAR_ANGLE_RANGE / 2f + mThumbStrokeWidthAsDegrees,
                alpha);
    }

    private void drawTrack(Canvas canvas, float beginAngle, float endAngle, float alpha) {
        // Angular distance between end and begin
        float angleBetweenEndAndBegin = endAngle - beginAngle;
        // The sweep angle for the track is the angular distance between end and begin less the
        // thumb width twice to account for top and bottom arc formed by the ROUND stroke style
        float sweepAngle = angleBetweenEndAndBegin - 2 * mThumbStrokeWidthAsDegrees;

        float startAngle = -1f;
        float strokeWidth = -1f;
        if (sweepAngle > 0f) {
            // The angle is greater than 0 which means a normal arc should be drawn with stroke
            // width same as the thumb. The ROUND stroke style will cover the top/bottom arc of the
            // track
            startAngle = beginAngle + mThumbStrokeWidthAsDegrees;
            strokeWidth = mThumbPaint.getStrokeWidth();
        } else if (Math.abs(sweepAngle) < 2 * mThumbStrokeWidthAsDegrees) {
            // The sweep angle is less than 0 but is still relevant in creating a circle for the
            // top/bottom track. The start angle is adjusted to account for being the mid point of
            // begin / end angle.
            startAngle = beginAngle + angleBetweenEndAndBegin / 2;
            // The radius of this circle forms a kite with the radius of the arc drawn for the rect
            // with the given angular difference between the arc radius which is used to compute the
            // new stroke width.
            strokeWidth = getKiteEdge(((mRect.right - mRect.left) / 2), angleBetweenEndAndBegin);
            // The opacity is decreased proportionally, if the stroke width of the track is 50% or
            // less that that of the thumb
            alpha = alpha * Math.min(1f, 2 * strokeWidth / mThumbPaint.getStrokeWidth());
            // As we desire a circle to be drawn, the sweep angle is set to a minimal value
            sweepAngle = Float.MIN_NORMAL;
        } else {
            return;
        }

        applyTrackColor(alpha);
        mTrackPaint.setStrokeWidth(strokeWidth);
        drawArc(canvas, startAngle, sweepAngle, mTrackPaint);
    }

    private void drawArc(Canvas canvas, float startAngle, float sweepAngle, Paint paint) {
        if (mDrawToLeft) {
            canvas.drawArc(mRect, /* startAngle= */ 180 - startAngle, -sweepAngle, false, paint);
        } else {
            canvas.drawArc(mRect, startAngle, sweepAngle, /* useCenter= */ false, paint);
        }
    }
}
+8 −0
Original line number Diff line number Diff line
@@ -117,3 +117,11 @@ flag {
    bug: "341021569"
    is_fixed_read_only: true
}

flag {
    name: "use_refactored_round_scrollbar"
    namespace: "wear_frameworks"
    description: "Use refactored round scrollbar."
    bug: "333417898"
    is_fixed_read_only: true
}
 No newline at end of file
+146 −0
Original line number Diff line number Diff line
/*
 * Copyright 2024 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
 *
 *      https://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 android.view;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.platform.test.annotations.Presubmit;
import android.platform.test.annotations.RequiresFlagsDisabled;
import android.platform.test.annotations.RequiresFlagsEnabled;
import android.platform.test.flag.junit.CheckFlagsRule;
import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.view.flags.Flags;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

/**
 * Tests for {@link RoundScrollbarRenderer}.
 *
 * <p>Build/Install/Run: atest FrameworksCoreTests:android.view.RoundScrollbarRendererTest
 */
@RunWith(AndroidJUnit4.class)
@SmallTest
@Presubmit
public class RoundScrollbarRendererTest {

    private static final int DEFAULT_VERTICAL_SCROLL_RANGE = 100;
    private static final int DEFAULT_VERTICAL_SCROLL_EXTENT = 20;
    private static final int DEFAULT_VERTICAL_SCROLL_OFFSET = 40;
    private static final float DEFAULT_ALPHA = 0.5f;
    private static final Rect BOUNDS = new Rect(0, 0, 200, 200);

    @Rule
    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();

    @Mock private Canvas mCanvas;
    @Captor private ArgumentCaptor<Paint> mPaintCaptor;
    private RoundScrollbarRenderer mScrollbar;

    @Before
    public void setup() {
        MockitoAnnotations.initMocks(this);

        MockView view = spy(new MockView(ApplicationProvider.getApplicationContext()));
        when(view.canScrollVertically(anyInt())).thenReturn(true);
        when(view.computeVerticalScrollRange()).thenReturn(DEFAULT_VERTICAL_SCROLL_RANGE);
        when(view.computeVerticalScrollExtent()).thenReturn(DEFAULT_VERTICAL_SCROLL_EXTENT);
        when(view.computeVerticalScrollOffset()).thenReturn(DEFAULT_VERTICAL_SCROLL_OFFSET);
        mPaintCaptor = ArgumentCaptor.forClass(Paint.class);

        mScrollbar = new RoundScrollbarRenderer(view);
    }

    @Test
    @RequiresFlagsDisabled(Flags.FLAG_USE_REFACTORED_ROUND_SCROLLBAR)
    public void testScrollbarDrawn_legacy() {
        mScrollbar.drawRoundScrollbars(mCanvas, DEFAULT_ALPHA, BOUNDS, /* drawToLeft= */ false);

        // The arc will be drawn twice, i.e. once for track and once for thumb
        verify(mCanvas, times(2))
                .drawArc(any(), anyFloat(), anyFloat(), eq(false), mPaintCaptor.capture());

        Paint thumbPaint = mPaintCaptor.getAllValues().getFirst();
        assertEquals(Paint.Cap.ROUND, thumbPaint.getStrokeCap());
        assertEquals(Paint.Style.STROKE, thumbPaint.getStyle());
        Paint trackPaint = mPaintCaptor.getAllValues().get(1);
        assertEquals(Paint.Cap.ROUND, trackPaint.getStrokeCap());
        assertEquals(Paint.Style.STROKE, trackPaint.getStyle());
    }

    @Test
    @RequiresFlagsEnabled(Flags.FLAG_USE_REFACTORED_ROUND_SCROLLBAR)
    public void testScrollbarDrawn() {
        mScrollbar.drawRoundScrollbars(mCanvas, DEFAULT_ALPHA, BOUNDS, /* drawToLeft= */ false);

        // The arc will be drawn thrice, i.e. twice for track and once for thumb
        verify(mCanvas, times(3))
                .drawArc(any(), anyFloat(), anyFloat(), eq(false), mPaintCaptor.capture());

        // Verify paint styles
        Paint thumbPaint = mPaintCaptor.getAllValues().getFirst();
        assertEquals(Paint.Cap.ROUND, thumbPaint.getStrokeCap());
        assertEquals(Paint.Style.STROKE, thumbPaint.getStyle());
        Paint trackPaint = mPaintCaptor.getAllValues().get(1);
        assertEquals(Paint.Cap.ROUND, trackPaint.getStrokeCap());
        assertEquals(Paint.Style.STROKE, trackPaint.getStyle());
    }

    public static class MockView extends View {

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

        @Override
        public int computeVerticalScrollRange() {
            return super.getHeight();
        }

        @Override
        public int computeVerticalScrollOffset() {
            return super.computeVerticalScrollOffset();
        }

        @Override
        public int computeVerticalScrollExtent() {
            return super.computeVerticalScrollExtent();
        }
    }
}