1 /*
2  * Copyright (C) 2018 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.car.settings.common;
18 
19 import android.content.Context;
20 import android.content.res.TypedArray;
21 import android.os.Parcel;
22 import android.os.Parcelable;
23 import android.util.AttributeSet;
24 import android.util.Log;
25 import android.view.KeyEvent;
26 import android.view.MotionEvent;
27 import android.view.View;
28 import android.widget.SeekBar;
29 import android.widget.TextView;
30 
31 import androidx.preference.PreferenceViewHolder;
32 
33 import com.android.car.settings.R;
34 import com.android.car.ui.preference.CarUiPreference;
35 import com.android.car.ui.utils.DirectManipulationHelper;
36 
37 /**
38  * Car Setting's own version of SeekBarPreference.
39  *
40  * The code is directly taken from androidx.preference.SeekBarPreference. However it has 1 main
41  * functionality difference. There is a new field which can enable continuous updates while the
42  * seek bar value is changing. This can be set programmatically by using the {@link
43  * #setContinuousUpdate() setContinuousUpdate} method.
44  */
45 public class SeekBarPreference extends CarUiPreference {
46 
47     private int mSeekBarValue;
48     private int mMin;
49     private int mMax;
50     private int mSeekBarIncrement;
51     private boolean mTrackingTouch;
52     private SeekBar mSeekBar;
53     private TextView mSeekBarValueTextView;
54     private boolean mAdjustable; // whether the seekbar should respond to the left/right keys
55     private boolean mShowSeekBarValue; // whether to show the seekbar value TextView next to the bar
56     private boolean mContinuousUpdate; // whether scrolling provides continuous calls to listener
57     private boolean mInDirectManipulationMode;
58 
59     private static final String TAG = "SeekBarPreference";
60 
61     /**
62      * Listener reacting to the SeekBar changing value by the user
63      */
64     private final SeekBar.OnSeekBarChangeListener mSeekBarChangeListener =
65             new SeekBar.OnSeekBarChangeListener() {
66                 @Override
67                 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
68                     if (fromUser && (mContinuousUpdate || !mTrackingTouch)) {
69                         syncValueInternal(seekBar);
70                     }
71                 }
72 
73                 @Override
74                 public void onStartTrackingTouch(SeekBar seekBar) {
75                     mTrackingTouch = true;
76                 }
77 
78                 @Override
79                 public void onStopTrackingTouch(SeekBar seekBar) {
80                     mTrackingTouch = false;
81                     if (seekBar.getProgress() + mMin != mSeekBarValue) {
82                         syncValueInternal(seekBar);
83                     }
84                 }
85             };
86 
87     /**
88      * Listener reacting to the user pressing DPAD left/right keys if {@code
89      * adjustable} attribute is set to true; it transfers the key presses to the SeekBar
90      * to be handled accordingly. Also handles entering and exiting direct manipulation
91      * mode for rotary.
92      */
93     private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() {
94         @Override
95         public boolean onKey(View v, int keyCode, KeyEvent event) {
96             // Don't allow events through if there is no SeekBar, the SeekBar is disabled,
97             // or we're in non-adjustable mode.
98             if (mSeekBar == null || !mSeekBar.isEnabled() || !mAdjustable) {
99                 return false;
100             }
101 
102             // Consume nudge events in direct manipulation mode.
103             if (mInDirectManipulationMode
104                     && (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
105                     || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
106                     || keyCode == KeyEvent.KEYCODE_DPAD_UP
107                     || keyCode == KeyEvent.KEYCODE_DPAD_DOWN)) {
108                 return true;
109             }
110 
111             // Handle events to enter or exit direct manipulation mode.
112             if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
113                 if (event.getAction() == KeyEvent.ACTION_DOWN) {
114                     setInDirectManipulationMode(v, !mInDirectManipulationMode);
115                 }
116                 return true;
117             }
118             if (keyCode == KeyEvent.KEYCODE_BACK) {
119                 if (mInDirectManipulationMode) {
120                     if (event.getAction() == KeyEvent.ACTION_DOWN) {
121                         setInDirectManipulationMode(v, false);
122                     }
123                     return true;
124                 }
125             }
126 
127             // Don't propagate confirm keys to the SeekBar to prevent a ripple effect on the thumb.
128             if (KeyEvent.isConfirmKey(keyCode)) {
129                 return false;
130             }
131 
132             if (event.getAction() == KeyEvent.ACTION_DOWN) {
133                 return mSeekBar.onKeyDown(keyCode, event);
134             } else {
135                 return mSeekBar.onKeyUp(keyCode, event);
136             }
137         }
138     };
139 
140     /** Listener to exit rotary direct manipulation mode when the user switches to touch. */
141     private final View.OnFocusChangeListener mSeekBarFocusChangeListener =
142             (v, hasFocus) -> {
143                 if (!hasFocus && mInDirectManipulationMode && mSeekBar != null) {
144                     setInDirectManipulationMode(v, false);
145                 }
146             };
147 
148     /** Listener to handle rotate events from the rotary controller in direct manipulation mode. */
149     private final View.OnGenericMotionListener mSeekBarScrollListener = (v, event) -> {
150         if (!mInDirectManipulationMode || !mAdjustable || mSeekBar == null) {
151             return false;
152         }
153         int adjustment = Math.round(event.getAxisValue(MotionEvent.AXIS_SCROLL));
154         if (adjustment == 0) {
155             return false;
156         }
157         int count = Math.abs(adjustment);
158         int keyCode = adjustment < 0 ? KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT;
159         KeyEvent downEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
160                 KeyEvent.ACTION_DOWN, keyCode, /* repeat= */ 0);
161         KeyEvent upEvent = new KeyEvent(event.getDownTime(), event.getEventTime(),
162                 KeyEvent.ACTION_UP, keyCode, /* repeat= */ 0);
163         for (int i = 0; i < count; i++) {
164             mSeekBar.onKeyDown(keyCode, downEvent);
165             mSeekBar.onKeyUp(keyCode, upEvent);
166         }
167         return true;
168     };
169 
SeekBarPreference( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)170     public SeekBarPreference(
171             Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
172         super(context, attrs, defStyleAttr, defStyleRes);
173 
174         TypedArray a = context.obtainStyledAttributes(
175                 attrs, R.styleable.SeekBarPreference, defStyleAttr, defStyleRes);
176 
177         /**
178          * The ordering of these two statements are important. If we want to set max first, we need
179          * to perform the same steps by changing min/max to max/min as following:
180          * mMax = a.getInt(...) and setMin(...).
181          */
182         mMin = a.getInt(R.styleable.SeekBarPreference_min, 0);
183         setMax(a.getInt(R.styleable.SeekBarPreference_android_max, 100));
184         setSeekBarIncrement(a.getInt(R.styleable.SeekBarPreference_seekBarIncrement, 0));
185         mAdjustable = a.getBoolean(R.styleable.SeekBarPreference_adjustable, true);
186         mShowSeekBarValue = a.getBoolean(R.styleable.SeekBarPreference_showSeekBarValue, true);
187         a.recycle();
188     }
189 
SeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr)190     public SeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr) {
191         this(context, attrs, defStyleAttr, 0);
192     }
193 
SeekBarPreference(Context context, AttributeSet attrs)194     public SeekBarPreference(Context context, AttributeSet attrs) {
195         this(context, attrs, R.attr.seekBarPreferenceStyle);
196     }
197 
SeekBarPreference(Context context)198     public SeekBarPreference(Context context) {
199         this(context, null);
200     }
201 
202     @Override
onBindViewHolder(PreferenceViewHolder view)203     public void onBindViewHolder(PreferenceViewHolder view) {
204         super.onBindViewHolder(view);
205         view.itemView.setOnKeyListener(mSeekBarKeyListener);
206         view.itemView.setOnFocusChangeListener(mSeekBarFocusChangeListener);
207         view.itemView.setOnGenericMotionListener(mSeekBarScrollListener);
208 
209         mSeekBar = (SeekBar) view.findViewById(R.id.seekbar);
210         mSeekBarValueTextView = (TextView) view.findViewById(R.id.seekbar_value);
211         if (mShowSeekBarValue) {
212             mSeekBarValueTextView.setVisibility(View.VISIBLE);
213         } else {
214             mSeekBarValueTextView.setVisibility(View.GONE);
215             mSeekBarValueTextView = null;
216         }
217 
218         if (mSeekBar == null) {
219             Log.e(TAG, "SeekBar view is null in onBindViewHolder.");
220             return;
221         }
222         mSeekBar.setOnSeekBarChangeListener(mSeekBarChangeListener);
223         mSeekBar.setMax(mMax - mMin);
224         // If the increment is not zero, use that. Otherwise, use the default mKeyProgressIncrement
225         // in AbsSeekBar when it's zero. This default increment value is set by AbsSeekBar
226         // after calling setMax. That's why it's important to call setKeyProgressIncrement after
227         // calling setMax() since setMax() can change the increment value.
228         if (mSeekBarIncrement != 0) {
229             mSeekBar.setKeyProgressIncrement(mSeekBarIncrement);
230         } else {
231             mSeekBarIncrement = mSeekBar.getKeyProgressIncrement();
232         }
233 
234         mSeekBar.setProgress(mSeekBarValue - mMin);
235         if (mSeekBarValueTextView != null) {
236             mSeekBarValueTextView.setText(String.valueOf(mSeekBarValue));
237         }
238         boolean enabled = isEnabled() && !isUxRestricted();
239         mSeekBar.setEnabled(enabled);
240         if (!enabled && mInDirectManipulationMode) {
241             setInDirectManipulationMode(view.itemView, false);
242         }
243     }
244 
245     @Override
onSetInitialValue(boolean restoreValue, Object defaultValue)246     protected void onSetInitialValue(boolean restoreValue, Object defaultValue) {
247         setValue(restoreValue ? getPersistedInt(mSeekBarValue)
248                 : (Integer) defaultValue);
249     }
250 
251     @Override
onGetDefaultValue(TypedArray a, int index)252     protected Object onGetDefaultValue(TypedArray a, int index) {
253         return a.getInt(index, 0);
254     }
255 
256     /** Setter for the minimum value allowed on seek bar. */
setMin(int min)257     public void setMin(int min) {
258         if (min > mMax) {
259             min = mMax;
260         }
261         if (min != mMin) {
262             mMin = min;
263             notifyChanged();
264         }
265     }
266 
267     /** Getter for the minimum value allowed on seek bar. */
getMin()268     public int getMin() {
269         return mMin;
270     }
271 
272     /** Setter for the maximum value allowed on seek bar. */
setMax(int max)273     public final void setMax(int max) {
274         if (max < mMin) {
275             max = mMin;
276         }
277         if (max != mMax) {
278             mMax = max;
279             notifyChanged();
280         }
281     }
282 
283     /**
284      * Returns the amount of increment change via each arrow key click. This value is derived
285      * from
286      * user's specified increment value if it's not zero. Otherwise, the default value is picked
287      * from the default mKeyProgressIncrement value in {@link android.widget.AbsSeekBar}.
288      *
289      * @return The amount of increment on the SeekBar performed after each user's arrow key press.
290      */
getSeekBarIncrement()291     public final int getSeekBarIncrement() {
292         return mSeekBarIncrement;
293     }
294 
295     /**
296      * Sets the increment amount on the SeekBar for each arrow key press.
297      *
298      * @param seekBarIncrement The amount to increment or decrement when the user presses an
299      *                         arrow key.
300      */
setSeekBarIncrement(int seekBarIncrement)301     public final void setSeekBarIncrement(int seekBarIncrement) {
302         if (seekBarIncrement != mSeekBarIncrement) {
303             mSeekBarIncrement = Math.min(mMax - mMin, Math.abs(seekBarIncrement));
304             notifyChanged();
305         }
306     }
307 
308     /** Getter for the maximum value allowed on seek bar. */
getMax()309     public int getMax() {
310         return mMax;
311     }
312 
313     /** Setter for the functionality which allows for changing the values via keyboard arrows. */
setAdjustable(boolean adjustable)314     public void setAdjustable(boolean adjustable) {
315         mAdjustable = adjustable;
316     }
317 
318     /** Getter for the functionality which allows for changing the values via keyboard arrows. */
isAdjustable()319     public boolean isAdjustable() {
320         return mAdjustable;
321     }
322 
323     /** Setter for the functionality which allows for continuous triggering of listener code. */
setContinuousUpdate(boolean continuousUpdate)324     public void setContinuousUpdate(boolean continuousUpdate) {
325         mContinuousUpdate = continuousUpdate;
326     }
327 
328     /** Setter for the whether the text should be visible. */
setShowSeekBarValue(boolean showSeekBarValue)329     public void setShowSeekBarValue(boolean showSeekBarValue) {
330         mShowSeekBarValue = showSeekBarValue;
331     }
332 
333     /** Setter for the current value of the seek bar. */
setValue(int seekBarValue)334     public void setValue(int seekBarValue) {
335         setValueInternal(seekBarValue, true);
336     }
337 
setValueInternal(int seekBarValue, boolean notifyChanged)338     private void setValueInternal(int seekBarValue, boolean notifyChanged) {
339         if (seekBarValue < mMin) {
340             seekBarValue = mMin;
341         }
342         if (seekBarValue > mMax) {
343             seekBarValue = mMax;
344         }
345 
346         if (seekBarValue != mSeekBarValue) {
347             mSeekBarValue = seekBarValue;
348             if (mSeekBarValueTextView != null) {
349                 mSeekBarValueTextView.setText(String.valueOf(mSeekBarValue));
350             }
351             persistInt(seekBarValue);
352             if (notifyChanged) {
353                 notifyChanged();
354             }
355         }
356     }
357 
358     /** Getter for the current value of the seek bar. */
getValue()359     public int getValue() {
360         return mSeekBarValue;
361     }
362 
363     /**
364      * Persist the seekBar's seekbar value if callChangeListener
365      * returns true, otherwise set the seekBar's value to the stored value
366      */
syncValueInternal(SeekBar seekBar)367     private void syncValueInternal(SeekBar seekBar) {
368         int seekBarValue = mMin + seekBar.getProgress();
369         if (seekBarValue != mSeekBarValue) {
370             if (callChangeListener(seekBarValue)) {
371                 setValueInternal(seekBarValue, false);
372             } else {
373                 seekBar.setProgress(mSeekBarValue - mMin);
374             }
375         }
376     }
377 
setInDirectManipulationMode(View view, boolean enable)378     private void setInDirectManipulationMode(View view, boolean enable) {
379         mInDirectManipulationMode = enable;
380         DirectManipulationHelper.enableDirectManipulationMode(mSeekBar, enable);
381         // The preference is highlighted when it's focused with one exception. In direct
382         // manipulation (DM) mode, the SeekBar's thumb is highlighted instead. In DM mode, the
383         // preference and SeekBar are selected. The preference's highlight is drawn when it's
384         // focused but not selected, while the SeekBar's thumb highlight is drawn when the SeekBar
385         // is selected.
386         view.setSelected(enable);
387         mSeekBar.setSelected(enable);
388     }
389 
390     @Override
onSaveInstanceState()391     protected Parcelable onSaveInstanceState() {
392         final Parcelable superState = super.onSaveInstanceState();
393         if (isPersistent()) {
394             // No need to save instance state since it's persistent
395             return superState;
396         }
397 
398         // Save the instance state
399         final SeekBarPreference.SavedState myState = new SeekBarPreference.SavedState(superState);
400         myState.mSeekBarValue = mSeekBarValue;
401         myState.mMin = mMin;
402         myState.mMax = mMax;
403         return myState;
404     }
405 
406     @Override
onRestoreInstanceState(Parcelable state)407     protected void onRestoreInstanceState(Parcelable state) {
408         if (!state.getClass().equals(SeekBarPreference.SavedState.class)) {
409             // Didn't save state for us in onSaveInstanceState
410             super.onRestoreInstanceState(state);
411             return;
412         }
413 
414         // Restore the instance state
415         SeekBarPreference.SavedState myState = (SeekBarPreference.SavedState) state;
416         super.onRestoreInstanceState(myState.getSuperState());
417         mSeekBarValue = myState.mSeekBarValue;
418         mMin = myState.mMin;
419         mMax = myState.mMax;
420         notifyChanged();
421     }
422 
423     /**
424      * SavedState, a subclass of {@link BaseSavedState}, will store the state
425      * of MyPreference, a subclass of Preference.
426      * <p>
427      * It is important to always call through to super methods.
428      */
429     private static class SavedState extends BaseSavedState {
430         int mSeekBarValue;
431         int mMin;
432         int mMax;
433 
SavedState(Parcel source)434         SavedState(Parcel source) {
435             super(source);
436 
437             // Restore the click counter
438             mSeekBarValue = source.readInt();
439             mMin = source.readInt();
440             mMax = source.readInt();
441         }
442 
443         @Override
writeToParcel(Parcel dest, int flags)444         public void writeToParcel(Parcel dest, int flags) {
445             super.writeToParcel(dest, flags);
446 
447             // Save the click counter
448             dest.writeInt(mSeekBarValue);
449             dest.writeInt(mMin);
450             dest.writeInt(mMax);
451         }
452 
SavedState(Parcelable superState)453         SavedState(Parcelable superState) {
454             super(superState);
455         }
456 
457         @SuppressWarnings("unused")
458         public static final Parcelable.Creator<SeekBarPreference.SavedState> CREATOR =
459                 new Parcelable.Creator<SeekBarPreference.SavedState>() {
460                     @Override
461                     public SeekBarPreference.SavedState createFromParcel(Parcel in) {
462                         return new SeekBarPreference.SavedState(in);
463                     }
464 
465                     @Override
466                     public SeekBarPreference.SavedState[] newArray(int size) {
467                         return new SeekBarPreference
468                                 .SavedState[size];
469                     }
470                 };
471     }
472 }
473