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