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.systemui.common.ui.view; 18 19 import android.annotation.IntDef; 20 import android.annotation.Nullable; 21 import android.content.Context; 22 import android.content.res.TypedArray; 23 import android.util.AttributeSet; 24 import android.view.LayoutInflater; 25 import android.view.View; 26 import android.view.ViewGroup; 27 import android.widget.ImageView; 28 import android.widget.LinearLayout; 29 import android.widget.SeekBar; 30 31 import com.android.internal.annotations.VisibleForTesting; 32 import com.android.systemui.res.R; 33 34 import java.lang.annotation.Retention; 35 import java.lang.annotation.RetentionPolicy; 36 37 /** 38 * The layout contains a seekbar whose progress could be modified 39 * through the icons on two ends of the seekbar. 40 */ 41 public class SeekBarWithIconButtonsView extends LinearLayout { 42 43 private static final int DEFAULT_SEEKBAR_MAX = 6; 44 private static final int DEFAULT_SEEKBAR_PROGRESS = 0; 45 private static final int DEFAULT_SEEKBAR_TICK_MARK = 0; 46 47 private ViewGroup mIconStartFrame; 48 private ViewGroup mIconEndFrame; 49 private ImageView mIconStart; 50 private ImageView mIconEnd; 51 private SeekBar mSeekbar; 52 private int mSeekBarChangeMagnitude = 1; 53 54 private boolean mSetProgressFromButtonFlag = false; 55 56 private SeekBarChangeListener mSeekBarListener = new SeekBarChangeListener(); 57 private String[] mStateLabels = null; 58 SeekBarWithIconButtonsView(Context context)59 public SeekBarWithIconButtonsView(Context context) { 60 this(context, null); 61 } 62 SeekBarWithIconButtonsView(Context context, AttributeSet attrs)63 public SeekBarWithIconButtonsView(Context context, AttributeSet attrs) { 64 this(context, attrs, 0); 65 } 66 SeekBarWithIconButtonsView(Context context, AttributeSet attrs, int defStyleAttr)67 public SeekBarWithIconButtonsView(Context context, AttributeSet attrs, int defStyleAttr) { 68 this(context, attrs, defStyleAttr, 0); 69 } 70 SeekBarWithIconButtonsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)71 public SeekBarWithIconButtonsView(Context context, 72 AttributeSet attrs, int defStyleAttr, int defStyleRes) { 73 super(context, attrs, defStyleAttr, defStyleRes); 74 75 LayoutInflater.from(context).inflate( 76 R.layout.seekbar_with_icon_buttons, this, /* attachToRoot= */ true); 77 78 mIconStartFrame = findViewById(R.id.icon_start_frame); 79 mIconEndFrame = findViewById(R.id.icon_end_frame); 80 mIconStart = findViewById(R.id.icon_start); 81 mIconEnd = findViewById(R.id.icon_end); 82 mSeekbar = findViewById(R.id.seekbar); 83 84 if (attrs != null) { 85 TypedArray typedArray = context.obtainStyledAttributes( 86 attrs, 87 R.styleable.SeekBarWithIconButtonsView_Layout, 88 defStyleAttr, defStyleRes 89 ); 90 int max = typedArray.getInt( 91 R.styleable.SeekBarWithIconButtonsView_Layout_max, DEFAULT_SEEKBAR_MAX); 92 int progress = typedArray.getInt( 93 R.styleable.SeekBarWithIconButtonsView_Layout_progress, 94 DEFAULT_SEEKBAR_PROGRESS); 95 mSeekbar.setMax(max); 96 setProgress(progress); 97 98 int iconStartFrameContentDescriptionId = typedArray.getResourceId( 99 R.styleable.SeekBarWithIconButtonsView_Layout_iconStartContentDescription, 100 /* defValue= */ 0); 101 int iconEndFrameContentDescriptionId = typedArray.getResourceId( 102 R.styleable.SeekBarWithIconButtonsView_Layout_iconEndContentDescription, 103 /* defValue= */ 0); 104 if (iconStartFrameContentDescriptionId != 0) { 105 final String contentDescription = 106 context.getString(iconStartFrameContentDescriptionId); 107 mIconStartFrame.setContentDescription(contentDescription); 108 } 109 if (iconEndFrameContentDescriptionId != 0) { 110 final String contentDescription = 111 context.getString(iconEndFrameContentDescriptionId); 112 mIconEndFrame.setContentDescription(contentDescription); 113 } 114 int tickMarkId = typedArray.getResourceId( 115 R.styleable.SeekBarWithIconButtonsView_Layout_tickMark, 116 DEFAULT_SEEKBAR_TICK_MARK); 117 if (tickMarkId != DEFAULT_SEEKBAR_TICK_MARK) { 118 mSeekbar.setTickMark(getResources().getDrawable(tickMarkId)); 119 } 120 mSeekBarChangeMagnitude = typedArray.getInt( 121 R.styleable.SeekBarWithIconButtonsView_Layout_seekBarChangeMagnitude, 122 /* defValue= */ 1); 123 } else { 124 mSeekbar.setMax(DEFAULT_SEEKBAR_MAX); 125 setProgress(DEFAULT_SEEKBAR_PROGRESS); 126 } 127 128 mSeekbar.setOnSeekBarChangeListener(mSeekBarListener); 129 130 mIconStartFrame.setOnClickListener((view) -> onIconStartClicked()); 131 mIconEndFrame.setOnClickListener((view) -> onIconEndClicked()); 132 } 133 setIconViewAndFrameEnabled(View iconView, boolean enabled)134 private static void setIconViewAndFrameEnabled(View iconView, boolean enabled) { 135 iconView.setEnabled(enabled); 136 final ViewGroup iconFrame = (ViewGroup) iconView.getParent(); 137 iconFrame.setEnabled(enabled); 138 } 139 140 /** 141 * Stores the String array we would like to use for describing the state of seekbar progress 142 * and updates the state description with current progress. 143 * 144 * @param labels The state descriptions to be announced for each progress. 145 */ setProgressStateLabels(String[] labels)146 public void setProgressStateLabels(String[] labels) { 147 mStateLabels = labels; 148 if (mStateLabels != null) { 149 setSeekbarStateDescription(); 150 } 151 } 152 153 /** 154 * Sets the state of seekbar based on current progress. The progress of seekbar is 155 * corresponding to the index of the string array. If the progress is larger than or equals 156 * to the length of the array, the state description is set to an empty string. 157 */ setSeekbarStateDescription()158 private void setSeekbarStateDescription() { 159 mSeekbar.setStateDescription( 160 (mSeekbar.getProgress() < mStateLabels.length) 161 ? mStateLabels[mSeekbar.getProgress()] : ""); 162 } 163 164 /** 165 * Sets a onSeekbarChangeListener to the seekbar in the layout. 166 * We update the Start Icon and End Icon if needed when the seekbar progress is changed. 167 */ 168 public void setOnSeekBarWithIconButtonsChangeListener( 169 @Nullable OnSeekBarWithIconButtonsChangeListener onSeekBarChangeListener) { 170 mSeekBarListener.setOnSeekBarWithIconButtonsChangeListener(onSeekBarChangeListener); 171 } 172 173 /** 174 * Only for testing. Get previous set mOnSeekBarChangeListener to the seekbar. 175 */ 176 @VisibleForTesting 177 public OnSeekBarWithIconButtonsChangeListener getOnSeekBarWithIconButtonsChangeListener() { 178 return mSeekBarListener.mOnSeekBarChangeListener; 179 } 180 181 /** 182 * Only for testing. Get mSeekBarListener to the seekbar. 183 */ 184 @VisibleForTesting 185 public SeekBarChangeListener getSeekBarChangeListener() { 186 return mSeekBarListener; 187 } 188 189 /** 190 * Only for testing. Get {@link #mSeekbar} in the layout. 191 */ 192 @VisibleForTesting 193 public SeekBar getSeekbar() { 194 return mSeekbar; 195 } 196 197 /** 198 * Start and End icons might need to be updated when there is a change in seekbar progress. 199 * Icon Start will need to be enabled when the seekbar progress is larger than 0. 200 * Icon End will need to be enabled when the seekbar progress is less than Max. 201 */ 202 private void updateIconViewIfNeeded(int progress) { 203 setIconViewAndFrameEnabled(mIconStart, progress > 0); 204 setIconViewAndFrameEnabled(mIconEnd, progress < mSeekbar.getMax()); 205 } 206 207 /** 208 * Sets max to the seekbar in the layout. 209 */ setMax(int max)210 public void setMax(int max) { 211 mSeekbar.setMax(max); 212 } 213 214 /** 215 * Gets max to the seekbar in the layout. 216 */ getMax()217 public int getMax() { 218 return mSeekbar.getMax(); 219 } 220 221 /** 222 * @return the magnitude by which seekbar progress changes when start and end icons are clicked. 223 */ getChangeMagnitude()224 public int getChangeMagnitude() { 225 return mSeekBarChangeMagnitude; 226 } 227 228 /** 229 * Sets progress to the seekbar in the layout. 230 * If the progress is smaller than or equals to 0, the IconStart will be disabled. If the 231 * progress is larger than or equals to Max, the IconEnd will be disabled. The seekbar progress 232 * will be constrained in {@link SeekBar}. 233 */ setProgress(int progress)234 public void setProgress(int progress) { 235 mSeekbar.setProgress(progress); 236 updateIconViewIfNeeded(mSeekbar.getProgress()); 237 } 238 setProgressFromButton(int progress)239 private void setProgressFromButton(int progress) { 240 mSetProgressFromButtonFlag = true; 241 mSeekbar.setProgress(progress); 242 updateIconViewIfNeeded(mSeekbar.getProgress()); 243 } 244 onIconStartClicked()245 private void onIconStartClicked() { 246 final int progress = mSeekbar.getProgress(); 247 if (progress > 0) { 248 setProgressFromButton(progress - mSeekBarChangeMagnitude); 249 } 250 } 251 onIconEndClicked()252 private void onIconEndClicked() { 253 final int progress = mSeekbar.getProgress(); 254 if (progress < mSeekbar.getMax()) { 255 setProgressFromButton(progress + mSeekBarChangeMagnitude); 256 } 257 } 258 259 /** 260 * Get current seekbar progress 261 * 262 * @return 263 */ 264 @VisibleForTesting getProgress()265 public int getProgress() { 266 return mSeekbar.getProgress(); 267 } 268 269 /** 270 * Extended from {@link SeekBar.OnSeekBarChangeListener} to add callback to notify the listeners 271 * the user interaction with the SeekBarWithIconButtonsView is finalized. 272 */ 273 public interface OnSeekBarWithIconButtonsChangeListener 274 extends SeekBar.OnSeekBarChangeListener { 275 276 @Retention(RetentionPolicy.SOURCE) 277 @IntDef({ 278 ControlUnitType.SLIDER, 279 ControlUnitType.BUTTON 280 }) 281 /** Denotes the Last user interacted control unit type. */ 282 @interface ControlUnitType { 283 int SLIDER = 0; 284 int BUTTON = 1; 285 } 286 287 /** 288 * Notification that the user interaction with SeekBarWithIconButtonsView is finalized. This 289 * would be triggered after user ends dragging on the slider or clicks icon buttons. This is 290 * not called if the progress change was not initiated by the user. 291 * 292 * @param seekBar The SeekBar in which the user ends interaction with 293 * @param control The last user interacted control unit. It would be 294 * {@link ControlUnitType#SLIDER} if the user was changing the seekbar 295 * progress through dragging the slider, or {@link ControlUnitType#BUTTON} 296 * is the user was clicking button to change the progress. 297 */ onUserInteractionFinalized(SeekBar seekBar, @ControlUnitType int control)298 void onUserInteractionFinalized(SeekBar seekBar, @ControlUnitType int control); 299 } 300 301 @VisibleForTesting 302 public class SeekBarChangeListener implements SeekBar.OnSeekBarChangeListener { 303 private OnSeekBarWithIconButtonsChangeListener mOnSeekBarChangeListener = null; 304 private boolean mSeekByTouch = false; 305 306 @Override onProgressChanged(SeekBar seekBar, int progress, boolean fromUser)307 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { 308 if (mStateLabels != null) { 309 setSeekbarStateDescription(); 310 } 311 if (mOnSeekBarChangeListener != null) { 312 if (mSetProgressFromButtonFlag) { 313 mSetProgressFromButtonFlag = false; 314 mOnSeekBarChangeListener.onProgressChanged( 315 seekBar, progress, /* fromUser= */ true); 316 // Directly trigger onUserInteractionFinalized since the interaction 317 // (click button) is ended. 318 mOnSeekBarChangeListener.onUserInteractionFinalized( 319 seekBar, OnSeekBarWithIconButtonsChangeListener.ControlUnitType.BUTTON); 320 } else { 321 mOnSeekBarChangeListener.onProgressChanged(seekBar, progress, fromUser); 322 if (!mSeekByTouch && fromUser) { 323 // Accessibility users could change the progress of the seekbar without 324 // touching the seekbar or clicking the buttons. In this, {@code fromUser} 325 // will be true, and we will consider the interaction to be finished. 326 // The seekbar progress could be changed when {@code fromUser} is false 327 // when magnification scale is set by pinch-to-zoom, keyboard control, or 328 // other services. In this case, we don't need to take finalized actions 329 // for the progress change. 330 mOnSeekBarChangeListener.onUserInteractionFinalized( 331 seekBar, 332 OnSeekBarWithIconButtonsChangeListener.ControlUnitType.SLIDER); 333 } 334 } 335 } 336 updateIconViewIfNeeded(progress); 337 } 338 339 @Override onStartTrackingTouch(SeekBar seekBar)340 public void onStartTrackingTouch(SeekBar seekBar) { 341 mSeekByTouch = true; 342 if (mOnSeekBarChangeListener != null) { 343 mOnSeekBarChangeListener.onStartTrackingTouch(seekBar); 344 } 345 } 346 347 @Override onStopTrackingTouch(SeekBar seekBar)348 public void onStopTrackingTouch(SeekBar seekBar) { 349 mSeekByTouch = false; 350 if (mOnSeekBarChangeListener != null) { 351 mOnSeekBarChangeListener.onStopTrackingTouch(seekBar); 352 mOnSeekBarChangeListener.onUserInteractionFinalized( 353 seekBar, OnSeekBarWithIconButtonsChangeListener.ControlUnitType.SLIDER); 354 } 355 } 356 setOnSeekBarWithIconButtonsChangeListener( OnSeekBarWithIconButtonsChangeListener listener)357 void setOnSeekBarWithIconButtonsChangeListener( 358 OnSeekBarWithIconButtonsChangeListener listener) { 359 mOnSeekBarChangeListener = listener; 360 } 361 } 362 } 363