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