1 /*
2  * Copyright (C) 2023 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 
17 package com.android.DeviceAsWebcam.view;
18 
19 import android.animation.ObjectAnimator;
20 import android.content.Context;
21 import android.util.AttributeSet;
22 import android.util.Range;
23 import android.view.Gravity;
24 import android.view.LayoutInflater;
25 import android.view.MotionEvent;
26 import android.view.View;
27 import android.view.accessibility.AccessibilityEvent;
28 import android.view.animation.AccelerateDecelerateInterpolator;
29 import android.widget.FrameLayout;
30 import android.widget.SeekBar;
31 import android.widget.SeekBar.OnSeekBarChangeListener;
32 import android.widget.TextView;
33 
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 import androidx.core.util.Preconditions;
37 
38 import com.android.DeviceAsWebcam.R;
39 
40 import java.math.BigDecimal;
41 import java.math.RoundingMode;
42 import java.text.DecimalFormat;
43 
44 /**
45  * A custom zoom controller to allow users to adjust their preferred zoom ratio setting.
46  */
47 public class ZoomController extends FrameLayout {
48     /**
49      * Zoom UI toggle mode.
50      */
51     public static final int ZOOM_UI_TOGGLE_MODE = 0;
52     /**
53      * Zoom UI seek bar mode.
54      */
55     public static final int ZOOM_UI_SEEK_BAR_MODE = 1;
56     /**
57      * The max zoom progress of the controller.
58      */
59     private static final int MAX_ZOOM_PROGRESS = 100000;
60     /**
61      * The toggle UI auto-show duration in ms.
62      */
63     private static final int TOGGLE_UI_AUTO_SHOW_DURATION_MS = 1000;
64     private static final int TOGGLE_UI_AUTO_SHOW_DURATION_ACCESSIBILITY_MS = 7000;
65     /**
66      * The invalid x position used when translating the motion events to the seek bar progress.
67      */
68     private static final float INVALID_X_POSITION = -1.0f;
69     /**
70      * Current zoom UI mode.
71      */
72     private int mZoomUiMode = ZOOM_UI_TOGGLE_MODE;
73     private View mToggleUiOptions;
74     private View mTouchOverlay;
75     private View mToggleUiBackground;
76     private View mToggleButtonSelected;
77     private SeekBar mSeekBar;
78     private ZoomKnob mZoomKnob;
79     /**
80      * TextView of the low sticky zoom ratio value option item.
81      */
82     private TextView mToggleOptionLow;
83     /**
84      * TextView of the middle sticky zoom ratio value option item.
85      */
86     private TextView mToggleOptionMiddle;
87     /**
88      * TextView of the high sticky zoom ratio value option item.
89      */
90     private TextView mToggleOptionHigh;
91     /**
92      * Default low sticky zoom ratio value.
93      */
94     private float mDefaultLowStickyZoomRatio;
95     /**
96      * Default middle sticky zoom ratio value.
97      */
98     private float mDefaultMiddleStickyZoomRatio = 1.0f;
99     /**
100      * Default high sticky zoom ratio value.
101      */
102     private float mDefaultHighStickyZoomRatio;
103     /**
104      * Current low sticky zoom ratio value.
105      */
106     private float mCurrentLowStickyZoomRatio;
107     /**
108      * Current middle sticky zoom ratio value.
109      */
110     private float mCurrentMiddleStickyZoomRatio;
111     /**
112      * Current high sticky zoom ratio value.
113      */
114     private float mCurrentHighStickyZoomRatio;
115     /**
116      * The min supported zoom ratio value.
117      */
118     private float mMinZoomRatio;
119     /**
120      * The max supported zoom ratio value.
121      */
122     private float mMaxZoomRatio;
123     /**
124      * Current zoom ratio value.
125      */
126     private float mCurrentZoomRatio;
127     /**
128      * Current toggle option count.
129      */
130     private int mToggleOptionCount = 3;
131     private final Runnable mToggleUiAutoShowRunnable = () -> switchZoomUiMode(ZOOM_UI_TOGGLE_MODE);
132     /**
133      * The registered zoom ratio updated listener.
134      */
135     private OnZoomRatioUpdatedListener mOnZoomRatioUpdatedListener = null;
136     private boolean mFirstPositionSkipped = false;
137     private float mPreviousXPosition = INVALID_X_POSITION;
138 
139     /**
140      * Timeout for toggling between slider and buttons. This is
141      * {@link #TOGGLE_UI_AUTO_SHOW_DURATION_MS} normally, and increases to
142      * {@link #TOGGLE_UI_AUTO_SHOW_DURATION_ACCESSIBILITY_MS} when accessibility services are
143      * enabled.
144      */
145     private int mToggleAutoShowDurationMs = TOGGLE_UI_AUTO_SHOW_DURATION_MS;
146 
ZoomController(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)147     public ZoomController(@NonNull Context context, @Nullable AttributeSet attrs,
148             int defStyleAttr) {
149         super(context, attrs, defStyleAttr);
150     }
151 
ZoomController(@onNull Context context, @Nullable AttributeSet attrs)152     public ZoomController(@NonNull Context context, @Nullable AttributeSet attrs) {
153         super(context, attrs);
154     }
155 
ZoomController(@onNull Context context)156     public ZoomController(@NonNull Context context) {
157         super(context);
158     }
159 
160     /**
161      * Initializes the controller.
162      *
163      * @param layoutInflater to inflate the zoom ui layout
164      * @param zoomRatioRange the supported zoom ratio range
165      */
init(LayoutInflater layoutInflater, Range<Float> zoomRatioRange)166     public void init(LayoutInflater layoutInflater, Range<Float> zoomRatioRange) {
167         removeAllViews();
168         addView(layoutInflater.inflate(R.layout.zoom_controller, null));
169 
170         mToggleUiOptions = findViewById(R.id.zoom_ui_toggle_options);
171         mTouchOverlay = findViewById(R.id.zoom_ui_overlay);
172         mToggleUiBackground = findViewById(R.id.zoom_ui_toggle_background);
173         mToggleButtonSelected = findViewById(R.id.zoom_ui_toggle_btn_selected);
174         mSeekBar = findViewById(R.id.zoom_ui_seekbar_slider);
175         mZoomKnob = findViewById(R.id.zoom_ui_knob);
176         mToggleOptionLow = findViewById(R.id.zoom_ui_toggle_option_low);
177         mToggleOptionMiddle = findViewById(R.id.zoom_ui_toggle_option_middle);
178         mToggleOptionHigh = findViewById(R.id.zoom_ui_toggle_option_high);
179 
180         switchZoomUiMode(mZoomUiMode);
181 
182         mSeekBar.setMax(MAX_ZOOM_PROGRESS);
183         mZoomKnob.initialize(mSeekBar, MAX_ZOOM_PROGRESS);
184         setSupportedZoomRatioRange(zoomRatioRange);
185 
186         // Monitors the touch events on the toggle UI to update the zoom ratio value.
187         mTouchOverlay.setOnTouchListener((v, event) -> {
188             if (mZoomUiMode == ZOOM_UI_TOGGLE_MODE) {
189                 updateSelectedZoomToggleOptionByMotionEvent(event);
190             } else {
191                 updateSeekBarProgressByMotionEvent(event);
192             }
193             return false;
194         });
195 
196         mTouchOverlay.setOnClickListener(v -> {
197             // Empty click listener to ensure none of the elements underneath
198             // the overlay receive an event.
199         });
200         // Long click events will trigger to switch the zoom ui mode
201         mTouchOverlay.setOnLongClickListener(v -> {
202             switchZoomUiMode(ZOOM_UI_SEEK_BAR_MODE);
203             return false;
204         });
205 
206         mToggleOptionLow.setOnClickListener((v) ->
207                 setToggleUiZoomRatio(mCurrentLowStickyZoomRatio, 0));
208         mToggleOptionMiddle.setOnClickListener((v) ->
209                 setToggleUiZoomRatio(mCurrentMiddleStickyZoomRatio, 1));
210         mToggleOptionHigh.setOnClickListener((v) ->
211                 setToggleUiZoomRatio(mCurrentHighStickyZoomRatio, 2));
212 
213         mToggleUiOptions.setOnLongClickListener(v -> switchZoomUiMode(ZOOM_UI_SEEK_BAR_MODE));
214         mToggleOptionLow.setOnLongClickListener(v -> switchZoomUiMode(ZOOM_UI_SEEK_BAR_MODE));
215         mToggleOptionMiddle.setOnLongClickListener(v -> switchZoomUiMode(ZOOM_UI_SEEK_BAR_MODE));
216         mToggleOptionHigh.setOnLongClickListener(v -> switchZoomUiMode(ZOOM_UI_SEEK_BAR_MODE));
217 
218         mSeekBar.setOnSeekBarChangeListener(
219                 new OnSeekBarChangeListener() {
220                     @Override
221                     public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
222                         if (!fromUser) {
223                             return;
224                         }
225                         updateZoomKnobByProgress(progress);
226                         setZoomRatioInternal(convertProgressToZoomRatio(progress), true);
227                         resetToggleUiAutoShowRunnable();
228                     }
229 
230                     @Override
231                     public void onStartTrackingTouch(SeekBar seekBar) {
232                         mZoomKnob.setElevated(true);
233                         removeToggleUiAutoShowRunnable();
234                     }
235 
236                     @Override
237                     public void onStopTrackingTouch(SeekBar seekBar) {
238                         mZoomKnob.setElevated(false);
239                         resetToggleUiAutoShowRunnable();
240                     }
241                 }
242         );
243     }
244 
245     @Override
setEnabled(boolean enabled)246     public void setEnabled(boolean enabled) {
247         super.setEnabled(enabled);
248         // Force disable these controls so that AccessibilityTraversalAfter won't take effects on
249         // these controls when they are disabled.
250         mToggleUiOptions.setEnabled(enabled);
251         mToggleOptionLow.setEnabled(enabled);
252         mToggleOptionMiddle.setEnabled(enabled);
253         mToggleOptionHigh.setEnabled(enabled);
254     }
255 
256     /**
257      * Sets the supported zoom ratio range to the controller.
258      */
setSupportedZoomRatioRange(Range<Float> zoomRatioRange)259     public void setSupportedZoomRatioRange(Range<Float> zoomRatioRange) {
260         Preconditions.checkArgument(zoomRatioRange.getLower() > 0,
261                 "The minimal zoom ratio must be positive.");
262         mMinZoomRatio = zoomRatioRange.getLower();
263         mMaxZoomRatio = zoomRatioRange.getUpper();
264         mCurrentZoomRatio = 1.0f;
265 
266         // The default low sticky value will always be the min supported zoom ratio
267         mDefaultLowStickyZoomRatio = mMinZoomRatio;
268 
269         // Supports 3 toggle options if min supported zoom ratio is smaller than 1.0f and the max
270         // supported zoom ratio is larger than 2.0f after rounding
271         if (mMinZoomRatio < 0.95f && mMaxZoomRatio >= 2.05f) {
272             transformToggleUiByOptionCount(3);
273             // Sets the high sticky zoom ratio as 2.0f
274             mDefaultHighStickyZoomRatio = 2.0f;
275         } else {
276             transformToggleUiByOptionCount(2);
277             // Sets the high sticky zoom ratio as 2.0f if the max supported zoom ratio is larger
278             // than 2.0f after rounding. Otherwise, sets it as the max supported zoom ratio value.
279             mDefaultHighStickyZoomRatio = mMaxZoomRatio >= 2.05f ? 2.0f : mMaxZoomRatio;
280         }
281         updateToggleOptionValues();
282         removeToggleUiAutoShowRunnable();
283         switchZoomUiMode(ZOOM_UI_TOGGLE_MODE);
284     }
285 
286     /**
287      * Updates the toggle option values according to current zoom ratio value.
288      *
289      * <p>If the camera device supports the min zoom ratio smaller than 1.0 and the max zoom ratio
290      * larger than 2.0, three toggle options are supported:
291      *     - In the beginning, three sticky value options [smallest zoom ratio value, 1.0, 2.0]
292      *     will be provided.
293      *     - After end users change the zoom ratio:
294      *       - If the new zoom ratio setting is smaller than 1.0, the sticky value options will
295      *       become [new zoom ratio value, 1.0, 2.0].
296      *       - If the new zoom ratio setting is ">= 1.0" and "< 2.0", the sticky value options will
297      *       become [smallest zoom ratio value, new zoom ratio value, 2.0].
298      *       - If the new zoom ratio setting is ">= 2.0", the sticky value options will become
299      *       [smallest zoom ratio value, 1.0, new zoom ratio value].
300      *
301      * <p>Otherwise, two toggle options are supported:
302      *     - In the beginning, two sticky value options [smallest zoom ratio value,
303      *     min(2.0, largest zoom ratio value)] will be provided.
304      *     - After end users change the zoom ratio:
305      *       - If the new zoom ratio setting is ">= smallest zoom ratio value" and
306      *       "< min(2.0, largest zoom ratio value)", the sticky value options will become
307      *       [new zoom ratio value, min(2.0, largest zoom ratio value)].
308      *       - If the new zoom ratio setting is ">= min92.0, largest zoom ratio value)", the sticky
309      *       value options will become [smallest zoom ratio value, new zoom ratio value]
310      */
updateToggleOptionValues()311     private void updateToggleOptionValues() {
312         if (mToggleOptionCount == 3) {
313             mToggleOptionMiddle.setText(convertZoomRatioToString(mCurrentMiddleStickyZoomRatio));
314             if (mCurrentZoomRatio < (mDefaultMiddleStickyZoomRatio - 0.05f)) {
315                 setSelectedZoomToggleOption(0);
316                 mCurrentLowStickyZoomRatio = mCurrentZoomRatio;
317                 mCurrentMiddleStickyZoomRatio = mDefaultMiddleStickyZoomRatio;
318                 mCurrentHighStickyZoomRatio = mDefaultHighStickyZoomRatio;
319             } else if (mCurrentZoomRatio >= (mDefaultMiddleStickyZoomRatio - 0.05f)
320                     && mCurrentZoomRatio < (mDefaultHighStickyZoomRatio - 0.05f)) {
321                 setSelectedZoomToggleOption(1);
322                 mCurrentLowStickyZoomRatio = roundZoomRatio(mMinZoomRatio);
323                 mCurrentMiddleStickyZoomRatio = mCurrentZoomRatio;
324                 mCurrentHighStickyZoomRatio = mDefaultHighStickyZoomRatio;
325             } else {
326                 setSelectedZoomToggleOption(2);
327                 mCurrentLowStickyZoomRatio = roundZoomRatio(mMinZoomRatio);
328                 mCurrentMiddleStickyZoomRatio = mDefaultMiddleStickyZoomRatio;
329                 mCurrentHighStickyZoomRatio = mCurrentZoomRatio;
330             }
331             mToggleOptionLow.setText(convertZoomRatioToString(mCurrentLowStickyZoomRatio));
332             mToggleOptionMiddle.setText(convertZoomRatioToString(mCurrentMiddleStickyZoomRatio));
333             mToggleOptionHigh.setText(convertZoomRatioToString(mCurrentHighStickyZoomRatio));
334         } else {
335             mToggleOptionLow.setText(convertZoomRatioToString(mCurrentLowStickyZoomRatio));
336             if (mCurrentZoomRatio < (mDefaultHighStickyZoomRatio - 0.05f)) {
337                 setSelectedZoomToggleOption(0);
338                 mCurrentLowStickyZoomRatio = mCurrentZoomRatio;
339                 mCurrentHighStickyZoomRatio = mDefaultHighStickyZoomRatio;
340             } else {
341                 setSelectedZoomToggleOption(2);
342                 mCurrentLowStickyZoomRatio = mDefaultLowStickyZoomRatio;
343                 mCurrentHighStickyZoomRatio = mCurrentZoomRatio;
344             }
345             mToggleOptionLow.setText(convertZoomRatioToString(mCurrentLowStickyZoomRatio));
346             mToggleOptionHigh.setText(convertZoomRatioToString(mCurrentHighStickyZoomRatio));
347         }
348     }
349 
350     /**
351      * Sets the text display rotation of the text in the controller.
352      */
setTextDisplayRotation(int rotation, int animationDurationMs)353     public void setTextDisplayRotation(int rotation, int animationDurationMs) {
354         ObjectAnimator anim1 = ObjectAnimator.ofFloat(mToggleOptionLow,
355                         /*propertyName=*/"rotation", rotation)
356                 .setDuration(animationDurationMs);
357         anim1.setInterpolator(new AccelerateDecelerateInterpolator());
358         anim1.start();
359         ObjectAnimator anim2 = ObjectAnimator.ofFloat(mToggleOptionMiddle,
360                         /*propertyName=*/"rotation", rotation)
361                 .setDuration(animationDurationMs);
362         anim2.setInterpolator(new AccelerateDecelerateInterpolator());
363         anim2.start();
364         ObjectAnimator anim3 = ObjectAnimator.ofFloat(mToggleOptionHigh,
365                         /*propertyName=*/"rotation", rotation)
366                 .setDuration(animationDurationMs);
367         anim3.setInterpolator(new AccelerateDecelerateInterpolator());
368         anim3.start();
369         ObjectAnimator animZoomKnob = ObjectAnimator.ofFloat(mZoomKnob,
370                         /*propertyName=*/"rotation", rotation)
371                 .setDuration(animationDurationMs);
372         animZoomKnob.setInterpolator(new AccelerateDecelerateInterpolator());
373         animZoomKnob.start();
374     }
375 
376     /**
377      * Sets zoom ratio value to the controller.
378      */
setZoomRatio(float zoomRatio, int zoomUiMode)379     public void setZoomRatio(float zoomRatio, int zoomUiMode) {
380         setZoomRatioInternal(zoomRatio, false);
381         updateZoomKnobByZoomRatio(zoomRatio);
382         mSeekBar.setProgress(convertZoomRatioToProgress(zoomRatio));
383         switchZoomUiMode(zoomUiMode);
384         resetToggleUiAutoShowRunnable();
385     }
386 
387     /**
388      * Sets zoom ratio value and notify the zoom ratio change to the listener according to the
389      * input notifyZoomRatioChange value.
390      */
setZoomRatioInternal(float zoomRatio, boolean notifyZoomRatioChange)391     private void setZoomRatioInternal(float zoomRatio, boolean notifyZoomRatioChange) {
392         float roundedZoomRatio = roundZoomRatio(
393                 Math.max(mMinZoomRatio, Math.min(zoomRatio, mMaxZoomRatio)));
394 
395         if (mCurrentZoomRatio != roundedZoomRatio && notifyZoomRatioChange
396                 && mOnZoomRatioUpdatedListener != null) {
397             mOnZoomRatioUpdatedListener.onValueChanged(roundedZoomRatio);
398         }
399 
400         boolean sendAccessibilityEvent = roundedZoomRatio != mCurrentZoomRatio &&
401                                          (int)
402                                             (Math.floor(roundedZoomRatio) -
403                                                 Math.floor(mCurrentZoomRatio)) != 0;
404 
405         mCurrentZoomRatio = roundedZoomRatio;
406         updateToggleOptionValues();
407         mSeekBar.setStateDescription(Float.toString(mCurrentZoomRatio));
408         mToggleUiOptions.setStateDescription(Float.toString(mCurrentZoomRatio));
409         if (sendAccessibilityEvent) {
410             mSeekBar.sendAccessibilityEvent(AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT);
411         }
412     }
413 
414     /**
415      * Sets an {@link OnZoomRatioUpdatedListener} to receive zoom ratio changes from the controller.
416      */
setOnZoomRatioUpdatedListener(OnZoomRatioUpdatedListener listener)417     public void setOnZoomRatioUpdatedListener(OnZoomRatioUpdatedListener listener) {
418         mOnZoomRatioUpdatedListener = listener;
419     }
420 
421     /**
422      * Method to be called if Accessibility Services are enabled/disabled. This should
423      * be called by the parent activity/fragment to ensure that ZoomController is more
424      * more functional when used with Accessibility Services.
425      *
426      * @param enabled whether accessibility services are enabled or not
427      */
onAccessibilityServicesEnabled(boolean enabled)428     public void onAccessibilityServicesEnabled(boolean enabled) {
429         if (mTouchOverlay == null) {
430             return;
431         }
432 
433         // Hide the overlay as touch events don't work well with Accessibility Services
434         // When Accessibility Services are enabled, we provide a somewhat less refined,
435         // but more accessible UX flow for changing zoom.
436         if (enabled) {
437             mTouchOverlay.setVisibility(View.GONE);
438             mToggleAutoShowDurationMs = TOGGLE_UI_AUTO_SHOW_DURATION_ACCESSIBILITY_MS;
439         } else {
440             mTouchOverlay.setVisibility(View.VISIBLE);
441             mToggleAutoShowDurationMs = TOGGLE_UI_AUTO_SHOW_DURATION_MS;
442         }
443     }
444 
445     /**
446      * Converts the input float zoom ratio value to string which is rounded with
447      * RoundingMode.HALF_UP to one decimal digit.
448      */
convertZoomRatioToString(float zoomRatio)449     static String convertZoomRatioToString(float zoomRatio) {
450         DecimalFormat zoomRatioDf = new DecimalFormat("0.0");
451         zoomRatioDf.setRoundingMode(RoundingMode.HALF_UP);
452         return zoomRatioDf.format(roundZoomRatio(zoomRatio));
453     }
454 
455     /**
456      * Rounds the input float zoom ratio value with RoundingMode.HALF_UP to one decimal digit.
457      */
roundZoomRatio(float zoomRatio)458     static float roundZoomRatio(float zoomRatio) {
459         // Keep one decimal digit since; we also follow the same in convertZoomRatioToString()
460         BigDecimal bigDec = new BigDecimal(zoomRatio);
461         return bigDec.setScale(1, RoundingMode.HALF_UP).floatValue();
462     }
463 
464     /**
465      * Switches the UI to the toggle or seek bar mode.
466      */
switchZoomUiMode(int zoomUiMode)467     private boolean switchZoomUiMode(int zoomUiMode) {
468         mZoomUiMode = zoomUiMode;
469         int toggleUiVisibility = (zoomUiMode == ZOOM_UI_TOGGLE_MODE) ? View.VISIBLE : View.GONE;
470         mToggleUiOptions.setVisibility(toggleUiVisibility);
471         mToggleButtonSelected.setVisibility(toggleUiVisibility);
472         mToggleUiBackground.setVisibility(toggleUiVisibility);
473 
474         int seekBarUiVisibility = (zoomUiMode == ZOOM_UI_SEEK_BAR_MODE) ? View.VISIBLE : View.GONE;
475         mSeekBar.setVisibility(seekBarUiVisibility);
476         mZoomKnob.setVisibility(seekBarUiVisibility);
477 
478         return false;
479     }
480 
481     /**
482      * Transforms the toggle button UI layout for the desired option count.
483      *
484      * <p>The medium toggle option will be hidden when toggle option count is 2. The layout width
485      * will also be shorten to only keep the space for two toggle buttons.
486      *
487      * @param toggleOptionCount only 2 or 3 toggle option count is supported.
488      */
transformToggleUiByOptionCount(int toggleOptionCount)489     private void transformToggleUiByOptionCount(int toggleOptionCount) {
490         mToggleOptionCount = toggleOptionCount;
491         int layoutWidth;
492 
493         switch (toggleOptionCount) {
494             case 2 -> {
495                 layoutWidth = getResources().getDimensionPixelSize(
496                         R.dimen.zoom_ui_toggle_two_options_layout_width);
497                 mToggleOptionMiddle.setVisibility(View.GONE);
498                 setSelectedZoomToggleOption(0);
499             }
500             case 3 -> {
501                 layoutWidth = getResources().getDimensionPixelSize(
502                         R.dimen.zoom_ui_toggle_three_options_layout_width);
503                 mToggleOptionMiddle.setVisibility(View.VISIBLE);
504                 setSelectedZoomToggleOption(1);
505             }
506             default -> throw new IllegalArgumentException("Unsupported toggle option count!");
507         }
508 
509         LayoutParams lp = (LayoutParams) mToggleUiOptions.getLayoutParams();
510         lp.width = layoutWidth;
511         mToggleUiOptions.setLayoutParams(lp);
512 
513         lp = (LayoutParams) mToggleUiBackground.getLayoutParams();
514         lp.width = layoutWidth;
515         mToggleUiBackground.setLayoutParams(lp);
516     }
517 
518     /**
519      * Updates the selected zoom toggle option by the motion events.
520      *
521      * <p>Mark the toggle option as selected when the motion event is in their own layout range.
522      */
updateSelectedZoomToggleOptionByMotionEvent(MotionEvent event)523     private void updateSelectedZoomToggleOptionByMotionEvent(MotionEvent event) {
524         float updatedZoomRatio;
525         int toggleOption;
526 
527         int zoomToggleUiWidth = mToggleUiOptions.getWidth();
528         if (event.getX() <= zoomToggleUiWidth / mToggleOptionCount) {
529             toggleOption = 0;
530             updatedZoomRatio = mCurrentLowStickyZoomRatio;
531         } else if (event.getX()
532                 > zoomToggleUiWidth * (mToggleOptionCount - 1) / mToggleOptionCount) {
533             toggleOption = 2;
534             updatedZoomRatio = mCurrentHighStickyZoomRatio;
535         } else {
536             toggleOption = 1;
537             updatedZoomRatio = mCurrentMiddleStickyZoomRatio;
538         }
539 
540         setToggleUiZoomRatio(updatedZoomRatio, toggleOption);
541     }
542 
setToggleUiZoomRatio(float currentStickyZoomRatio, int currentZoomToggleOption)543     private void setToggleUiZoomRatio(float currentStickyZoomRatio,
544                                       int currentZoomToggleOption) {
545         setSelectedZoomToggleOption(currentZoomToggleOption);
546         // Updates the knob seek bar and zoom ratio value according to the newly selected option.
547         if (currentStickyZoomRatio != mCurrentZoomRatio) {
548             updateZoomKnobByZoomRatio(currentStickyZoomRatio);
549             mSeekBar.setProgress(convertZoomRatioToProgress(currentStickyZoomRatio));
550             setZoomRatioInternal(currentStickyZoomRatio, true);
551         }
552     }
553 
554     /**
555      * Sets the specific zoom toggle option UI as selected.
556      */
setSelectedZoomToggleOption(int optionIndex)557     private void setSelectedZoomToggleOption(int optionIndex) {
558         mToggleOptionLow.setStateDescription(null);
559         mToggleOptionMiddle.setStateDescription(null);
560         mToggleOptionHigh.setStateDescription(null);
561 
562         String stateDesc = mContext.getString(R.string.zoom_ratio_button_current_description);
563         LayoutParams lp = (LayoutParams) mToggleButtonSelected.getLayoutParams();
564         switch (optionIndex) {
565             case 0 -> {
566                 lp.leftMargin = getResources().getDimensionPixelSize(
567                         R.dimen.zoom_ui_toggle_padding);
568                 lp.rightMargin = 0;
569                 lp.gravity = Gravity.CENTER_VERTICAL | Gravity.LEFT;
570                 mToggleOptionLow.setStateDescription(stateDesc);
571             }
572             case 1 -> {
573                 lp.leftMargin = 0;
574                 lp.rightMargin = 0;
575                 lp.gravity = Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL;
576                 mToggleOptionMiddle.setStateDescription(stateDesc);
577             }
578             case 2 -> {
579                 lp.leftMargin = 0;
580                 lp.rightMargin = getResources().getDimensionPixelSize(
581                         R.dimen.zoom_ui_toggle_padding);
582                 lp.gravity = Gravity.CENTER_VERTICAL | Gravity.RIGHT;
583                 mToggleOptionHigh.setStateDescription(stateDesc);
584             }
585             default -> throw new IllegalArgumentException("Unsupported toggle option index!");
586         }
587         mToggleButtonSelected.setLayoutParams(lp);
588     }
589 
590     /**
591      * Updates the seek bar progress by the motion events.
592      *
593      * <p>The seek bar is disabled until the end users long-click on the toggle button options and
594      * a ACTION_UP motion event is received. When the seek bar is disabled, the motion events will
595      * be translated to the new progress values and updated to the knob and seek bar.
596      */
updateSeekBarProgressByMotionEvent(MotionEvent event)597     private void updateSeekBarProgressByMotionEvent(MotionEvent event) {
598         if (mPreviousXPosition == INVALID_X_POSITION) {
599             if (!mFirstPositionSkipped) {
600                 mFirstPositionSkipped = true;
601             } else {
602                 mPreviousXPosition = event.getX();
603             }
604             return;
605         }
606 
607         mZoomKnob.setElevated(event.getAction() != MotionEvent.ACTION_UP);
608 
609         int seekBarWidth = mSeekBar.getWidth();
610         float zoomRatio = roundZoomRatio(mCurrentZoomRatio
611                 + (mMaxZoomRatio - mMinZoomRatio) * (event.getX() - mPreviousXPosition)
612                 / (float) seekBarWidth);
613         zoomRatio = Math.max(mMinZoomRatio, Math.min(zoomRatio, mMaxZoomRatio));
614         updateZoomKnobByZoomRatio(zoomRatio);
615         mSeekBar.setProgress(convertZoomRatioToProgress(zoomRatio));
616         setZoomRatioInternal(zoomRatio, true);
617 
618         if (event.getAction() == MotionEvent.ACTION_UP) {
619             mFirstPositionSkipped = false;
620             mPreviousXPosition = INVALID_X_POSITION;
621             resetToggleUiAutoShowRunnable();
622         } else {
623             mPreviousXPosition = event.getX();
624         }
625     }
626 
627     /**
628      * Updates the zoom knob by the progress value.
629      */
updateZoomKnobByProgress(int progress)630     private void updateZoomKnobByProgress(int progress) {
631         mZoomKnob.updateZoomProgress(progress, convertProgressToZoomRatio(progress));
632     }
633 
634     /**
635      * Converts the progress value to the zoom ratio value.
636      */
convertProgressToZoomRatio(int progress)637     private float convertProgressToZoomRatio(int progress) {
638         return roundZoomRatio(
639                 mMinZoomRatio + (mMaxZoomRatio - mMinZoomRatio) * progress / MAX_ZOOM_PROGRESS);
640     }
641 
642     /**
643      * Updates the zoom knob by the zoom ratio value.
644      */
updateZoomKnobByZoomRatio(float zoomRatio)645     private void updateZoomKnobByZoomRatio(float zoomRatio) {
646         mZoomKnob.updateZoomProgress(convertZoomRatioToProgress(zoomRatio), zoomRatio);
647     }
648 
649     /**
650      * Converts the zoom ratio to the progress value value.
651      */
convertZoomRatioToProgress(float zoomRatio)652     private int convertZoomRatioToProgress(float zoomRatio) {
653         return (int) ((zoomRatio - mMinZoomRatio) / (mMaxZoomRatio - mMinZoomRatio)
654                 * MAX_ZOOM_PROGRESS);
655     }
656 
657     /**
658      * Resets the toggle UI auto-show runnable.
659      */
resetToggleUiAutoShowRunnable()660     private void resetToggleUiAutoShowRunnable() {
661         removeToggleUiAutoShowRunnable();
662         postDelayed(mToggleUiAutoShowRunnable, mToggleAutoShowDurationMs);
663     }
664 
665     /**
666      * Removes the toggle UI auto-show runnable.
667      */
removeToggleUiAutoShowRunnable()668     private void removeToggleUiAutoShowRunnable() {
669         removeCallbacks(mToggleUiAutoShowRunnable);
670     }
671 
672     /**
673      * The listener to monitor the value change of the zoom controller.
674      */
675     public interface OnZoomRatioUpdatedListener {
676 
677         /**
678          * Invoked when the zoom ratio value is changed.
679          *
680          * @param value the updated zoom ratio value.
681          */
onValueChanged(float value)682         void onValueChanged(float value);
683     }
684 }
685