1 /*
2  * Copyright (C) 2016 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.settings.widget;
18 
19 import static android.view.HapticFeedbackConstants.CLOCK_TICK;
20 
21 import android.content.Context;
22 import android.graphics.Rect;
23 import android.os.Bundle;
24 import android.util.AttributeSet;
25 import android.view.MotionEvent;
26 import android.view.View;
27 import android.view.accessibility.AccessibilityEvent;
28 import android.widget.RadioButton;
29 import android.widget.RadioGroup;
30 import android.widget.SeekBar;
31 
32 import androidx.core.view.ViewCompat;
33 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
34 import androidx.customview.widget.ExploreByTouchHelper;
35 
36 import java.util.List;
37 
38 /**
39  * LabeledSeekBar represent a seek bar assigned with labeled, discrete values.
40  * It pretends to be a group of radio button for AccessibilityServices, in order to adjust the
41  * behavior of these services to keep the mental model of the visual discrete SeekBar.
42  */
43 public class LabeledSeekBar extends SeekBar {
44 
45     private final ExploreByTouchHelper mAccessHelper;
46 
47     /** Seek bar change listener set via public method. */
48     private OnSeekBarChangeListener mOnSeekBarChangeListener;
49 
50     /** Labels for discrete progress values. */
51     private String[] mLabels;
52 
53     private int mLastProgress = -1;
54 
LabeledSeekBar(Context context, AttributeSet attrs)55     public LabeledSeekBar(Context context, AttributeSet attrs) {
56         this(context, attrs, com.android.internal.R.attr.seekBarStyle);
57     }
58 
LabeledSeekBar(Context context, AttributeSet attrs, int defStyleAttr)59     public LabeledSeekBar(Context context, AttributeSet attrs, int defStyleAttr) {
60         this(context, attrs, defStyleAttr, 0);
61     }
62 
LabeledSeekBar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)63     public LabeledSeekBar(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
64         super(context, attrs, defStyleAttr, defStyleRes);
65 
66         mAccessHelper = new LabeledSeekBarExploreByTouchHelper(this);
67         ViewCompat.setAccessibilityDelegate(this, mAccessHelper);
68 
69         super.setOnSeekBarChangeListener(mProxySeekBarListener);
70     }
71 
72     @Override
setProgress(int progress)73     public synchronized void setProgress(int progress) {
74         // This method gets called from the constructor, so mAccessHelper may
75         // not have been assigned yet.
76         if (mAccessHelper != null) {
77             mAccessHelper.invalidateRoot();
78         }
79 
80         super.setProgress(progress);
81     }
82 
setLabels(String[] labels)83     public void setLabels(String[] labels) {
84         mLabels = labels;
85     }
86 
87     @Override
setOnSeekBarChangeListener(OnSeekBarChangeListener l)88     public void setOnSeekBarChangeListener(OnSeekBarChangeListener l) {
89         // The callback set in the constructor will proxy calls to this
90         // listener.
91         mOnSeekBarChangeListener = l;
92     }
93 
94     @Override
dispatchHoverEvent(MotionEvent event)95     protected boolean dispatchHoverEvent(MotionEvent event) {
96         return mAccessHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
97     }
98 
sendClickEventForAccessibility(int progress)99     private void sendClickEventForAccessibility(int progress) {
100         mAccessHelper.invalidateRoot();
101         mAccessHelper.sendEventForVirtualView(progress, AccessibilityEvent.TYPE_VIEW_CLICKED);
102     }
103 
104     private final OnSeekBarChangeListener mProxySeekBarListener = new OnSeekBarChangeListener() {
105         @Override
106         public void onStopTrackingTouch(SeekBar seekBar) {
107             if (mOnSeekBarChangeListener != null) {
108                 mOnSeekBarChangeListener.onStopTrackingTouch(seekBar);
109             }
110         }
111 
112         @Override
113         public void onStartTrackingTouch(SeekBar seekBar) {
114             if (mOnSeekBarChangeListener != null) {
115                 mOnSeekBarChangeListener.onStartTrackingTouch(seekBar);
116             }
117         }
118 
119         @Override
120         public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
121             if (mOnSeekBarChangeListener != null) {
122                 mOnSeekBarChangeListener.onProgressChanged(seekBar, progress, fromUser);
123                 sendClickEventForAccessibility(progress);
124             }
125             if (progress != mLastProgress) {
126                 seekBar.performHapticFeedback(CLOCK_TICK);
127                 mLastProgress = progress;
128             }
129         }
130     };
131 
132     private class LabeledSeekBarExploreByTouchHelper extends ExploreByTouchHelper {
133 
134         private boolean mIsLayoutRtl;
135 
LabeledSeekBarExploreByTouchHelper(LabeledSeekBar forView)136         public LabeledSeekBarExploreByTouchHelper(LabeledSeekBar forView) {
137             super(forView);
138             mIsLayoutRtl = forView.getResources().getConfiguration()
139                     .getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
140         }
141 
142         @Override
getVirtualViewAt(float x, float y)143         protected int getVirtualViewAt(float x, float y) {
144             return getVirtualViewIdIndexFromX(x);
145         }
146 
147         @Override
getVisibleVirtualViews(List<Integer> list)148         protected void getVisibleVirtualViews(List<Integer> list) {
149             for (int i = 0, c = LabeledSeekBar.this.getMax(); i <= c; ++i) {
150                 list.add(i);
151             }
152         }
153 
154         @Override
onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments)155         protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
156                 Bundle arguments) {
157             if (virtualViewId == ExploreByTouchHelper.HOST_ID) {
158                 // Do nothing
159                 return false;
160             }
161 
162             switch (action) {
163                 case AccessibilityNodeInfoCompat.ACTION_CLICK:
164                     LabeledSeekBar.this.setProgress(virtualViewId);
165                     sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_CLICKED);
166                     return true;
167                 default:
168                     return false;
169             }
170         }
171 
172         @Override
onPopulateNodeForVirtualView( int virtualViewId, AccessibilityNodeInfoCompat node)173         protected void onPopulateNodeForVirtualView(
174                 int virtualViewId, AccessibilityNodeInfoCompat node) {
175             node.setClassName(RadioButton.class.getName());
176             node.setBoundsInParent(getBoundsInParentFromVirtualViewId(virtualViewId));
177             node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
178             node.setContentDescription(mLabels[virtualViewId]);
179             node.setClickable(true);
180             node.setCheckable(true);
181             node.setChecked(virtualViewId == LabeledSeekBar.this.getProgress());
182         }
183 
184         @Override
onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)185         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
186             event.setClassName(RadioButton.class.getName());
187             event.setContentDescription(mLabels[virtualViewId]);
188             event.setChecked(virtualViewId == LabeledSeekBar.this.getProgress());
189         }
190 
191         @Override
onPopulateNodeForHost(AccessibilityNodeInfoCompat node)192         protected void onPopulateNodeForHost(AccessibilityNodeInfoCompat node) {
193             node.setClassName(RadioGroup.class.getName());
194         }
195 
196         @Override
onPopulateEventForHost(AccessibilityEvent event)197         protected void onPopulateEventForHost(AccessibilityEvent event) {
198             event.setClassName(RadioGroup.class.getName());
199         }
200 
getHalfVirtualViewWidth()201         private int getHalfVirtualViewWidth() {
202             final int width = LabeledSeekBar.this.getWidth();
203             final int barWidth = width - LabeledSeekBar.this.getPaddingStart()
204                     - LabeledSeekBar.this.getPaddingEnd();
205             return Math.max(0, barWidth / (LabeledSeekBar.this.getMax() * 2));
206         }
207 
getVirtualViewIdIndexFromX(float x)208         private int getVirtualViewIdIndexFromX(float x) {
209             int posBase = Math.max(0,
210                     ((int) x - LabeledSeekBar.this.getPaddingStart()) / getHalfVirtualViewWidth());
211             posBase = (posBase + 1) / 2;
212             posBase = Math.min(posBase, LabeledSeekBar.this.getMax());
213             return mIsLayoutRtl ? LabeledSeekBar.this.getMax() - posBase : posBase;
214         }
215 
getBoundsInParentFromVirtualViewId(int virtualViewId)216         private Rect getBoundsInParentFromVirtualViewId(int virtualViewId) {
217             final int updatedVirtualViewId = mIsLayoutRtl
218                     ? LabeledSeekBar.this.getMax() - virtualViewId : virtualViewId;
219             int left = (updatedVirtualViewId * 2 - 1) * getHalfVirtualViewWidth()
220                     + LabeledSeekBar.this.getPaddingStart();
221             int right = (updatedVirtualViewId * 2 + 1) * getHalfVirtualViewWidth()
222                     + LabeledSeekBar.this.getPaddingStart();
223 
224             // Edge case
225             left = updatedVirtualViewId == 0 ? 0 : left;
226             right = updatedVirtualViewId == LabeledSeekBar.this.getMax()
227                     ? LabeledSeekBar.this.getWidth() : right;
228 
229             final Rect r = new Rect();
230             r.set(left, 0, right, LabeledSeekBar.this.getHeight());
231             return r;
232         }
233     }
234 }
235