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