1 /*
2  * Copyright (C) 2020 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.rotaryplayground;
18 
19 import static com.android.car.rotaryplayground.DirectManipulationHandler.setDirectManipulationHandler;
20 
21 import android.os.Bundle;
22 import android.view.KeyEvent;
23 import android.view.LayoutInflater;
24 import android.view.MotionEvent;
25 import android.view.View;
26 import android.view.ViewGroup;
27 import android.view.ViewParent;
28 import android.view.accessibility.AccessibilityNodeInfo;
29 import android.widget.NumberPicker;
30 import android.widget.TimePicker;
31 
32 import androidx.annotation.Nullable;
33 import androidx.fragment.app.Fragment;
34 
35 import java.util.ArrayList;
36 import java.util.Collections;
37 import java.util.HashMap;
38 import java.util.List;
39 import java.util.Map;
40 
41 /**
42  * Fragment that demos rotary interactions directly manipulating the state of UI widgets such as a
43  * {@link android.widget.SeekBar}, {@link android.widget.DatePicker}, and
44  * {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView} in an
45  * application window.
46  */
47 public class RotaryDirectManipulationWidgets extends Fragment {
48 
49     // TODO(agathaman): refactor a common class that takes in a fragment xml id and inflates it, to
50     //  share between this and RotaryCards.
51 
52     private final DirectManipulationState mDirectManipulationMode = new DirectManipulationState();
53 
54     @Override
onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)55     public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
56             @Nullable Bundle savedInstanceState) {
57         View view = inflater.inflate(R.layout.rotary_direct_manipulation, container, false);
58 
59         DirectManipulationView dmv = view.findViewById(R.id.direct_manipulation_view);
60         setDirectManipulationHandler(dmv,
61                 new DirectManipulationHandler.Builder(mDirectManipulationMode)
62                         .setNudgeHandler(new DirectManipulationView.NudgeHandler())
63                         .setRotationHandler(new DirectManipulationView.RotationHandler())
64                         .build());
65 
66 
67         TimePicker spinnerTimePicker = view.findViewById(R.id.spinner_time_picker);
68         setDirectManipulationHandler(spinnerTimePicker,
69                 new DirectManipulationHandler.Builder(mDirectManipulationMode)
70                         .setNudgeHandler(new TimePickerNudgeHandler())
71                         .build());
72 
73         DirectManipulationHandler numberPickerListener =
74                 new DirectManipulationHandler.Builder(mDirectManipulationMode)
75                         .setNudgeHandler(new NumberPickerNudgeHandler())
76                         .setBackHandler((v, keyCode, event) -> {
77                             spinnerTimePicker.requestFocus();
78                             return true;
79                         })
80                         .setRotationHandler((v, motionEvent) -> {
81                             View focusedView = v.findFocus();
82                             if (focusedView instanceof NumberPicker) {
83                                 NumberPicker numberPicker = (NumberPicker) focusedView;
84                                 float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
85                                 numberPicker.setValue(numberPicker.getValue() + Math.round(scroll));
86                                 return true;
87                             }
88                             return false;
89                         })
90                         .build();
91 
92         List<NumberPicker> numberPickers = new ArrayList<>();
93         getNumberPickerDescendants(numberPickers, spinnerTimePicker);
94         for (int i = 0; i < numberPickers.size(); i++) {
95             setDirectManipulationHandler(numberPickers.get(i), numberPickerListener);
96         }
97 
98         setDirectManipulationHandler(view.findViewById(R.id.clock_time_picker),
99                 new DirectManipulationHandler.Builder(
100                         mDirectManipulationMode)
101                         // TODO(pardis): fix the behavior here. It does not nudge as expected.
102                         .setNudgeHandler(new TimePickerNudgeHandler())
103                         .setRotationHandler((v, motionEvent) -> {
104                             // TODO(pardis): fix the behavior here. It does not scroll as intended.
105                             float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
106                             View focusedView = v.findFocus();
107                             scrollView(focusedView, scroll);
108                             return true;
109                         })
110                         .build());
111 
112         setDirectManipulationHandler(
113                 view.findViewById(R.id.seek_bar),
114                 new DirectManipulationHandler.Builder(mDirectManipulationMode)
115                         .setRotationHandler(new DelegateToA11yScrollRotationHandler())
116                         .build());
117 
118         setDirectManipulationHandler(
119                 view.findViewById(R.id.radial_time_picker),
120                 new DirectManipulationHandler.Builder(mDirectManipulationMode)
121                         .setRotationHandler(new DelegateToA11yScrollRotationHandler())
122                         .build());
123 
124         return view;
125     }
126 
127     @Override
onPause()128     public void onPause() {
129         if (mDirectManipulationMode.isActive()) {
130             // To ensure that the user doesn't get stuck in direct manipulation mode, disable direct
131             // manipulation mode when the fragment is not interactive (e.g., a dialog shows up).
132             mDirectManipulationMode.disable();
133         }
134         super.onPause();
135     }
136 
137     /**
138      * A {@link View.OnGenericMotionListener} implementation that delegates handling the
139      * {@link MotionEvent} to the {@link AccessibilityNodeInfo#ACTION_SCROLL_FORWARD}
140      * or {@link AccessibilityNodeInfo#ACTION_SCROLL_BACKWARD} depending on the sign of the
141      * {@link MotionEvent#AXIS_SCROLL} value.
142      */
143     private static class DelegateToA11yScrollRotationHandler
144             implements View.OnGenericMotionListener {
145 
146         @Override
onGenericMotion(View v, MotionEvent event)147         public boolean onGenericMotion(View v, MotionEvent event) {
148             scrollView(v, event.getAxisValue(MotionEvent.AXIS_SCROLL));
149             return true;
150         }
151     }
152 
153     /**
154      * A shortcut to "scrolling" a given {@link View} by delegating to A11y actions. Most useful
155      * in scenarios that we do not have API access to the descendants of a {@link ViewGroup} but
156      * also handy for other cases so we don't have to re-implement the behaviors if we already know
157      * that suitable A11y actions exist and are implemented for the relevant views.
158      */
scrollView(View view, float scroll)159     private static void scrollView(View view, float scroll) {
160         for (int i = 0; i < Math.round(Math.abs(scroll)); i++) {
161             view.performAccessibilityAction(
162                     scroll > 0
163                             ? AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
164                             : AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
165                     /* arguments= */ null);
166         }
167     }
168 
169     /**
170      * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior
171      * for a {@link NumberPicker}.
172      *
173      * <p>
174      * This handler expects that it is being used in Direct Manipulation mode, i.e. as a directional
175      * delegate through a {@link DirectManipulationHandler} which can invoke it at the
176      * appropriate times.
177      * <p>
178      * Only handles the following {@link KeyEvent}s and in the specified way below:
179      *     <ul>
180      *         <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled
181      *         <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled
182      *         <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - nudges left
183      *         <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - nudges right
184      *     </ul>
185      * <p>
186      * This handler only allows nudging left and right to other {@link View} objects within the same
187      * {@link TimePicker}.
188      */
189     private static class NumberPickerNudgeHandler implements View.OnKeyListener {
190 
191         private static final Map<Integer, Integer> KEYCODE_TO_DIRECTION_MAP;
192 
193         static {
194             Map<Integer, Integer> map = new HashMap<>();
map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP)195             map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP);
map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN)196             map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN);
map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT)197             map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT);
map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT)198             map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT);
199             KEYCODE_TO_DIRECTION_MAP = Collections.unmodifiableMap(map);
200         }
201 
202         @Override
onKey(View view, int keyCode, KeyEvent event)203         public boolean onKey(View view, int keyCode, KeyEvent event) {
204             switch (keyCode) {
205                 case KeyEvent.KEYCODE_DPAD_UP:
206                 case KeyEvent.KEYCODE_DPAD_DOWN:
207                     // Disable by consuming the event and not doing anything.
208                     return true;
209                 case KeyEvent.KEYCODE_DPAD_LEFT:
210                 case KeyEvent.KEYCODE_DPAD_RIGHT:
211                     if (event.getAction() == KeyEvent.ACTION_UP) {
212                         int direction = KEYCODE_TO_DIRECTION_MAP.get(keyCode);
213                         View nextView = view.focusSearch(direction);
214                         if (areInTheSameTimePicker(view, nextView)) {
215                             nextView.requestFocus(direction);
216                         }
217                     }
218                     return true;
219                 default:
220                     return false;
221             }
222         }
223 
areInTheSameTimePicker(@ullable View view1, @Nullable View view2)224         private static boolean areInTheSameTimePicker(@Nullable View view1, @Nullable View view2) {
225             if (view1 == null || view2 == null) {
226                 return false;
227             }
228             TimePicker view1Ancestor = getTimePickerAncestor(view1);
229             TimePicker view2Ancestor = getTimePickerAncestor(view2);
230             if (view1Ancestor == null || view2Ancestor == null) {
231                 return false;
232             }
233             return view1Ancestor == view2Ancestor;
234         }
235 
236         /*
237          * A generic version of this may come in handy as a library. Any {@link ViewGroup} view that
238          * supports Direct Manipulation mode will need something like this to ensure nudge actions
239          * don't result in navigating outside the parent {link ViewGroup} that is in Direct
240          * Manipulation mode.
241          */
242         @Nullable
getTimePickerAncestor(@ullable View view)243         private static TimePicker getTimePickerAncestor(@Nullable View view) {
244             if (view instanceof TimePicker) {
245                 return (TimePicker) view;
246             }
247             ViewParent viewParent = view.getParent();
248             if (viewParent instanceof View) {
249                 return getTimePickerAncestor((View) viewParent);
250             }
251             return null;
252         }
253     }
254 
255     /**
256      * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior
257      * for a {@link TimePicker}.
258      * <p>
259      * This handler expects that it is being used in Direct Manipulation mode, i.e. as a
260      * directional delegate through a {@link DirectManipulationHandler} which can invoke it at the
261      * appropriate times.
262      * <p>
263      * Only handles the following {@link KeyEvent}s and in the specified way below:
264      *     <ul>
265      *         <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled
266      *         <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled
267      *         <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - passes focus to a descendant view
268      *         <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - passes focus to a descendant view
269      *     </ul>
270      * <p>
271      * When passing focus to a descendant, looks for all {@link NumberPicker} views and passes
272      * focus to the first one found.
273      * <p>
274      * This handler expects that any descendant {@link NumberPicker} objects have registered
275      * their own Direct Manipulation handlers via a {@link DirectManipulationHandler}.
276      */
277     private static class TimePickerNudgeHandler
278             implements View.OnKeyListener {
279 
280         @Override
onKey(View view, int keyCode, KeyEvent keyEvent)281         public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
282             if (!(view instanceof TimePicker)) {
283                 return false;
284             }
285             switch (keyCode) {
286                 case KeyEvent.KEYCODE_DPAD_UP:
287                 case KeyEvent.KEYCODE_DPAD_DOWN:
288                     // TODO(pardis): if intending to reuse this for both time pickers,
289                     //  then need to make sure it can distinguish between the two. For clock
290                     //  we may need up and down.
291                     // Disable by consuming the event and not doing anything.
292                     return true;
293                 case KeyEvent.KEYCODE_DPAD_LEFT:
294                 case KeyEvent.KEYCODE_DPAD_RIGHT:
295                     if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
296                         TimePicker timePicker = (TimePicker) view;
297                         List<NumberPicker> numberPickers = new ArrayList<>();
298                         getNumberPickerDescendants(numberPickers, timePicker);
299                         if (numberPickers.isEmpty()) {
300                             return false;
301                         }
302                         timePicker.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
303                         numberPickers.get(0).requestFocus();
304                     }
305                     return true;
306                 default:
307                     return false;
308             }
309         }
310 
311     }
312 
313     /*
314      * We don't have API access to the inner {@link View}s of a {@link TimePicker}. We do know based
315      * on {@code frameworks/base/core/res/res/layout/time_picker_legacy_material.xml} that a
316      * {@link TimePicker} that is in spinner mode will be using {@link NumberPicker}s internally,
317      * and that's what we rely on here.
318      */
getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v)319     private static void getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v) {
320         for (int i = 0; i < v.getChildCount(); i++) {
321             View child = v.getChildAt(i);
322             if (child instanceof NumberPicker) {
323                 numberPickers.add((NumberPicker) child);
324             } else if (child instanceof ViewGroup) {
325                 getNumberPickerDescendants(numberPickers, (ViewGroup) child);
326             }
327         }
328     }
329 }
330