Loading core/java/android/view/RoundScrollbarRenderer.java +191 −58 Original line number Diff line number Diff line Loading @@ -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% Loading @@ -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. Loading @@ -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) { Loading @@ -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) { Loading @@ -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); } } Loading @@ -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); } } Loading @@ -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); } } } core/java/android/view/flags/view_flags.aconfig +8 −0 Original line number Diff line number Diff line Loading @@ -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 core/tests/coretests/src/android/view/RoundScrollbarRendererTest.java 0 → 100644 +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(); } } } Loading
core/java/android/view/RoundScrollbarRenderer.java +191 −58 Original line number Diff line number Diff line Loading @@ -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% Loading @@ -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. Loading @@ -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) { Loading @@ -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) { Loading @@ -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); } } Loading @@ -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); } } Loading @@ -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); } } }
core/java/android/view/flags/view_flags.aconfig +8 −0 Original line number Diff line number Diff line Loading @@ -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
core/tests/coretests/src/android/view/RoundScrollbarRendererTest.java 0 → 100644 +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(); } } }