diff options
author | Pardis Beikzadeh <pardis@google.com> | 2020-05-12 09:37:54 -0700 |
---|---|---|
committer | Pardis Beikzadeh <pardis@google.com> | 2020-05-28 11:56:49 -0700 |
commit | 58158382afa4a72a1b8a2e7936c50dd42c4322a4 (patch) | |
tree | 1af9239f3560fd8a5518e62706c738a0ce21661c | |
parent | 653bdf987bcf2e2bb7e94288f52eba06cc5d4e38 (diff) | |
download | tests-58158382afa4a72a1b8a2e7936c50dd42c4322a4.tar.gz |
Add listeners for the direct manipulation example widgets.
BUG: 153886988
Test: make, install, ran the app
Change-Id: I2b18cee5ae84519316bfe98e551858257e268714
4 files changed, 540 insertions, 16 deletions
diff --git a/RotaryPlayground/res/layout/rotary_direct_manipulation.xml b/RotaryPlayground/res/layout/rotary_direct_manipulation.xml index 5b385b3..6f6990e 100644 --- a/RotaryPlayground/res/layout/rotary_direct_manipulation.xml +++ b/RotaryPlayground/res/layout/rotary_direct_manipulation.xml @@ -33,6 +33,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"> <TimePicker + android:id="@+id/spinner_time_picker" android:layout_width="wrap_content" android:layout_height="wrap_content" android:focusable="true" @@ -44,6 +45,7 @@ android:layout_height="0dp" android:layout_weight="1"> <TimePicker + android:id="@+id/clock_time_picker" android:layout_width="wrap_content" android:layout_height="wrap_content" android:focusable="true" @@ -59,11 +61,13 @@ android:orientation="vertical" android:layout_weight="1"> <SeekBar + android:id="@+id/seek_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/seek_bar_background"> </SeekBar> <RadialTimePickerView + android:id="@+id/radial_time_picker" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java new file mode 100644 index 0000000..5773b04 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.graphics.Color; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; +import androidx.core.util.Preconditions; + +import com.android.car.ui.utils.DirectManipulationHelper; + +/** + * A {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} that adds a + * "Direct Manipulation" mode to any {@link View} that uses it. + * <p> + * Direct Manipulation mode in the Rotary context is a mode in which the user can use the + * Rotary controls to manipulate and change the UI elements they are interacting with rather + * than navigate through the entire UI. + * <p> + * Treats {@link KeyEvent#KEYCODE_DPAD_CENTER} as the signal to enter Direct Manipulation + * mode, and {@link KeyEvent#KEYCODE_BACK} as the signal to exit, and keeps track of which + * mode the {@link View} using it is currently in. + * <p> + * When in Direct Manipulation mode, it delegates to {@code mDirectionalDelegate} + * for handling nudge behavior and {@code mMotionDelegate} for rotation behavior. Generally + * it is expected that in Direct Manipulation mode, nudges are used for navigation and + * rotation is used for "manipulating" the value of the selected {@link View}. + * <p> + * To reduce boilerplate, this class provides "no op" nudge and rotation behavior if + * no {@link View.OnKeyListener} or {@link View.OnGenericMotionListener} are provided as + * delegates for tackling the relevant events. + * <p> + * Allows {@link View}s that are within a {@link ViewGroup} to provide a link to their + * ancestor {@link ViewGroup} from which Direct Manipulation mode was first enabled. That way + * when the user finally exits Direct Manipulation mode, both objects are restored to their + * original state. + */ +public class DirectManipulationHandler implements View.OnKeyListener, + View.OnGenericMotionListener { + + /** Background color of a view when it's in direct manipulation mode. */ + private static final int BACKGROUND_COLOR_IN_DIRECT_MANIPULATION_MODE = Color.BLUE; + + /** Background color of a view when it's not in direct manipulation mode. */ + private static final int BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE = Color.TRANSPARENT; + + private final DirectManipulationState mDirectManipulationMode; + private final View.OnKeyListener mNudgeDelegate; + private final View.OnGenericMotionListener mRotationDelegate; + + /** + * A builder for {@link DirectManipulationHandler}. + */ + public static class Builder { + private final DirectManipulationState mDmState; + private View.OnKeyListener mNudgeDelegate; + private View.OnGenericMotionListener mRotationDelegate; + + public Builder(DirectManipulationState dmState) { + Preconditions.checkNotNull(dmState); + this.mDmState = dmState; + } + + public Builder setNudgeHandler(View.OnKeyListener directionalDelegate) { + Preconditions.checkNotNull(directionalDelegate); + this.mNudgeDelegate = directionalDelegate; + return this; + } + + public Builder setRotationHandler(View.OnGenericMotionListener motionDelegate) { + Preconditions.checkNotNull(motionDelegate); + this.mRotationDelegate = motionDelegate; + return this; + } + + public DirectManipulationHandler build() { + if (mNudgeDelegate == null && mRotationDelegate == null) { + throw new IllegalStateException("At least one delegate must be provided."); + } + return new DirectManipulationHandler(mDmState, mNudgeDelegate, mRotationDelegate); + } + } + + private DirectManipulationHandler(DirectManipulationState dmState, + @Nullable View.OnKeyListener nudgeDelegate, + @Nullable View.OnGenericMotionListener rotationDelegate) { + Preconditions.checkNotNull(dmState); + mDirectManipulationMode = dmState; + mNudgeDelegate = nudgeDelegate; + mRotationDelegate = rotationDelegate; + } + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; + Log.d("RotaryPlayGround", "View: " + view + "\n is handling " + keyCode + + " and action " + keyEvent.getAction() + + " having entered direct manipulation mode from " + + mDirectManipulationMode.getStartingView()); + + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + // If not yet in Direct Manipulation mode, switch to that mode. + // We generally want to give some kind of visual indication that this change + // has happened. In this example we change the background color. + if (!mDirectManipulationMode.isActive() && isActionUp) { + mDirectManipulationMode.setStartingView(view); + /* + * A more robust approach would be to fetch the current background color from + * the view object and store it back onto the View itself using the {@link + * View#setTag(int, java.lang.Object)} API. This could then be fetched back + * and used to restore the background color without needing to keep a constant + * reference to the color here which could fall out of sync with the xml files. + */ + view.setBackgroundColor(BACKGROUND_COLOR_IN_DIRECT_MANIPULATION_MODE); + DirectManipulationHelper.enableDirectManipulationMode(view, true); + } + return true; + case KeyEvent.KEYCODE_BACK: + // If in Direct Manipulation mode, exit, and clean up state. + if (mDirectManipulationMode.isActive() && isActionUp) { + // This may or may not be the same as argument v. It is possible + // for us to enter Direct Manipulation mode from view A and exit from + // view B. dmStartingView represents A and v represents B. + View dmStartingView = mDirectManipulationMode.getStartingView(); + // For ViewGroup objects, restore descendant focusability to + // FOCUS_BLOCK_DESCENDANTS so during non-Direct Manipulation mode, aka, + // general rotary navigation, we don't go through the individual inner UI + // elements. + if (dmStartingView instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) dmStartingView; + viewGroup.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } + // Restore any visual indicators that the view was in Direct Manipulation. + dmStartingView.setBackgroundColor( + BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE); + // Actually go ahead and disable Direct Manipulation mode for the view. + DirectManipulationHelper.enableDirectManipulationMode(dmStartingView, false); + // Update mode. + mDirectManipulationMode.setStartingView(null); + } + return true; + default: + // This handler is only responsible for behavior during Direct Manipulation + // mode. When the mode is disabled, ignore events. + if (!mDirectManipulationMode.isActive()) { + return false; + } + // If no delegate present, ignore events. + if (mNudgeDelegate == null) { + return false; + } + return mNudgeDelegate.onKey(view, keyCode, keyEvent); + } + } + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + // This handler is only responsible for behavior during Direct Manipulation + // mode. When the mode is disabled, ignore events. + if (!mDirectManipulationMode.isActive()) { + return false; + } + // If no delegate present, ignore events. + if (mRotationDelegate == null) { + return false; + } + return mRotationDelegate.onGenericMotion(v, event); + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java new file mode 100644 index 0000000..0227643 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * Keeps track of the state of "direct manipulation" Rotary mode for this application window by + * tracking a reference to the {@link View} from which the user first enters into "direct + * manipulation" mode. + * + * <p>See {@link DirectManipulationHandler} for a definition of "direct manipulation". + */ +public class DirectManipulationState { + + /** The view that is in direct manipulation mode, or null if none. */ + @Nullable private View mViewInDirectManipulationMode; + + public void setStartingView(@Nullable View view) { + mViewInDirectManipulationMode = view; + } + + /** + * Returns the {@link View} from which we entered into Direct Manipulation mode when that mode + * is active, and null otherwise. + */ + @Nullable public View getStartingView() { + return mViewInDirectManipulationMode; + } + + /** + * Returns true if Direct Manipulation mode is active, false otherwise. + */ + public boolean isActive() { + return mViewInDirectManipulationMode != null; + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java index fc99676..79a384c 100644 --- a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java @@ -23,6 +23,10 @@ import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.NumberPicker; +import android.widget.TimePicker; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -30,12 +34,20 @@ import androidx.fragment.app.Fragment; import com.android.car.ui.utils.DirectManipulationHelper; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + /** * Fragment that demos rotary interactions directly manipulating the state of UI widgets such as a - * {@link android.widget.SeekBar}, {@link android.widget.DatePicker}, - * {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView}. + * {@link android.widget.SeekBar}, {@link android.widget.DatePicker}, and + * {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView} in an + * application window. */ public class RotaryDirectManipulationWidgets extends Fragment { + // TODO(agathaman): refactor a common class that takes in a fragment xml id and inflates it, to // share between this and RotaryCards. @@ -51,24 +63,78 @@ public class RotaryDirectManipulationWidgets extends Fragment { /** Background color of a view when it's not in direct manipulation mode. */ private static final int BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE = Color.TRANSPARENT; - /** The view that is in direct manipulation mode, or null if none. */ - private View mViewInDirectManipulationMode; + private final DirectManipulationState mDirectManipulationMode = new DirectManipulationState(); @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.rotary_direct_manipulation, container, false); + DirectManipulationView dmv = view.findViewById(R.id.direct_manipulation_view); initDirectManipulationMode(dmv, /* handleNudge= */ true, /* handleRotate= */ true); + + TimePicker spinnerTimePicker = view.findViewById(R.id.spinner_time_picker); + registerDirectManipulationHandler(spinnerTimePicker, + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setNudgeHandler(new TimePickerNudgeHandler()) + .build()); + + DirectManipulationHandler numberPickerListener = + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setNudgeHandler(new NumberPickerNudgeHandler()) + .setRotationHandler((v, motionEvent) -> { + float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); + View focusedView = v.findFocus(); + if (focusedView instanceof NumberPicker) { + NumberPicker numberPicker = (NumberPicker) focusedView; + numberPicker.setValue(numberPicker.getValue() + Math.round(scroll)); + return true; + } + return false; + }) + .build(); + + List<NumberPicker> numberPickers = new ArrayList<>(); + getNumberPickerDescendants(numberPickers, spinnerTimePicker); + for (int i = 0; i < numberPickers.size(); i++) { + registerDirectManipulationHandler(numberPickers.get(i), numberPickerListener); + } + + registerDirectManipulationHandler(view.findViewById(R.id.clock_time_picker), + new DirectManipulationHandler.Builder( + mDirectManipulationMode) + // TODO(pardis): fix the behavior here. It does not nudge as expected. + .setNudgeHandler(new TimePickerNudgeHandler()) + .setRotationHandler((v, motionEvent) -> { + // TODO(pardis): fix the behavior here. It does not scroll as intended. + float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); + View focusedView = v.findFocus(); + scrollView(focusedView, scroll); + return true; + }) + .build()); + + registerDirectManipulationHandler( + view.findViewById(R.id.seek_bar), + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setRotationHandler(new DelegateToA11yScrollRotationHandler()) + .build()); + + registerDirectManipulationHandler( + view.findViewById(R.id.radial_time_picker), + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setRotationHandler(new DelegateToA11yScrollRotationHandler()) + .build()); + return view; } @Override public void onPause() { - if (mViewInDirectManipulationMode != null) { + if (mDirectManipulationMode.isActive()) { // To ensure that the user doesn't get stuck in direct manipulation mode, disable direct // manipulation mode when the fragment is not interactive (e.g., a dialog shows up). - enableDirectManipulationMode(mViewInDirectManipulationMode, false); + enableDirectManipulationMode(mDirectManipulationMode.getStartingView(), false); } super.onPause(); } @@ -88,12 +154,12 @@ public class RotaryDirectManipulationWidgets extends Fragment { switch (keyCode) { // Always consume KEYCODE_DPAD_CENTER and KEYCODE_BACK event. case KeyEvent.KEYCODE_DPAD_CENTER: - if (mViewInDirectManipulationMode == null && isActionUp) { + if (!mDirectManipulationMode.isActive() && isActionUp) { enableDirectManipulationMode(dmv, true); } return true; case KeyEvent.KEYCODE_BACK: - if (mViewInDirectManipulationMode != null && isActionUp) { + if (mDirectManipulationMode.isActive() && isActionUp) { enableDirectManipulationMode(dmv, false); } return true; @@ -103,7 +169,7 @@ public class RotaryDirectManipulationWidgets extends Fragment { case KeyEvent.KEYCODE_DPAD_DOWN: case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_RIGHT: - return handleNudge? handleNudgeEvent(keyEvent) : false; + return handleNudge ? handleNudgeEvent(keyEvent) : false; // Don't consume other key events. default: return false; @@ -125,20 +191,22 @@ public class RotaryDirectManipulationWidgets extends Fragment { : BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE); view.invalidate(); DirectManipulationHelper.enableDirectManipulationMode(view, enable); - mViewInDirectManipulationMode = enable ? view : null; + View currentView = enable ? view : null; + mDirectManipulationMode.setStartingView(currentView); } /** Handles controller nudge event. Returns whether the event was consumed. */ private boolean handleNudgeEvent(KeyEvent keyEvent) { - if (mViewInDirectManipulationMode == null) { + if (!mDirectManipulationMode.isActive()) { return false; } if (keyEvent.getAction() != KeyEvent.ACTION_UP) { return true; } int keyCode = keyEvent.getKeyCode(); - if (mViewInDirectManipulationMode instanceof DirectManipulationView) { - DirectManipulationView dmv = (DirectManipulationView) mViewInDirectManipulationMode; + if (mDirectManipulationMode.getStartingView() instanceof DirectManipulationView) { + DirectManipulationView dmv = + (DirectManipulationView) mDirectManipulationMode.getStartingView(); handleNudgeEvent(dmv, keyCode); return true; } @@ -150,11 +218,12 @@ public class RotaryDirectManipulationWidgets extends Fragment { /** Handles controller rotate event. Returns whether the event was consumed. */ private boolean handleRotateEvent(float scroll) { - if (mViewInDirectManipulationMode == null) { + if (!mDirectManipulationMode.isActive()) { return false; } - if (mViewInDirectManipulationMode instanceof DirectManipulationView) { - DirectManipulationView dmv = (DirectManipulationView) mViewInDirectManipulationMode; + if (mDirectManipulationMode.getStartingView() instanceof DirectManipulationView) { + DirectManipulationView dmv = + (DirectManipulationView) mDirectManipulationMode.getStartingView(); handleRotateEvent(dmv, scroll); return true; } @@ -188,4 +257,213 @@ public class RotaryDirectManipulationWidgets extends Fragment { private void handleRotateEvent(@NonNull DirectManipulationView dmv, float scroll) { dmv.zoom(DIRECT_MANIPULATION_VIEW_PX_PER_ROTATION * scroll); } + + /** + * Register the given {@link DirectManipulationHandler} as both the + * {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} for the given + * {@link View}. + * <p> + * Handles a {@link Nullable} {@link View} so that it can be used directly with the output of + * methods such as {@code findViewById}. + */ + private void registerDirectManipulationHandler(@Nullable View view, + DirectManipulationHandler handler) { + if (view == null) { + return; + } + view.setOnKeyListener(handler); + view.setOnGenericMotionListener(handler); + } + + /** + * A {@link View.OnGenericMotionListener} implementation that delegates handling the + * {@link MotionEvent} to the {@link AccessibilityNodeInfo#ACTION_SCROLL_FORWARD} + * or {@link AccessibilityNodeInfo#ACTION_SCROLL_BACKWARD} depending on the sign of the + * {@link MotionEvent#AXIS_SCROLL} value. + */ + private static class DelegateToA11yScrollRotationHandler + implements View.OnGenericMotionListener { + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + scrollView(v, event.getAxisValue(MotionEvent.AXIS_SCROLL)); + return true; + } + } + + /** + * A shortcut to "scrolling" a given {@link View} by delegating to A11y actions. Most useful + * in scenarios that we do not have API access to the descendants of a {@link ViewGroup} but + * also handy for other cases so we don't have to re-implement the behaviors if we already know + * that suitable A11y actions exist and are implemented for the relevant views. + */ + private static void scrollView(View view, float scroll) { + for (int i = 0; i < Math.round(Math.abs(scroll)); i++) { + view.performAccessibilityAction( + scroll > 0 + ? AccessibilityNodeInfo.ACTION_SCROLL_FORWARD + : AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, + /* arguments= */ null); + } + } + + /** + * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior + * for a {@link NumberPicker}. + * + * <p> + * This handler expects that it is being used in Direct Manipulation mode, i.e. as a directional + * delegate through a {@link DirectManipulationHandler} which can invoke it at the + * appropriate times. + * <p> + * Only handles the following {@link KeyEvent}s and in the specified way below: + * <ul> + * <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - nudges left + * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - nudges right + * </ul> + * <p> + * This handler only allows nudging left and right to other {@link View} objects within the same + * {@link TimePicker}. + */ + private static class NumberPickerNudgeHandler implements View.OnKeyListener { + + private static final Map<Integer, Integer> KEYCODE_TO_DIRECTION_MAP; + + static { + Map<Integer, Integer> map = new HashMap<>(); + map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP); + map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN); + map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT); + map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT); + KEYCODE_TO_DIRECTION_MAP = Collections.unmodifiableMap(map); + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + boolean isActionUp = event.getAction() == KeyEvent.ACTION_UP; + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + // Disable by consuming the event and not doing anything. + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (isActionUp) { + int direction = KEYCODE_TO_DIRECTION_MAP.get(keyCode); + View nextView = v.focusSearch(direction); + if (areInTheSameTimePicker(v, nextView)) { + nextView.requestFocus(direction); + } + } + return true; + default: + return false; + } + } + + private static boolean areInTheSameTimePicker(@Nullable View view1, @Nullable View view2) { + if (view1 == null || view2 == null) { + return false; + } + TimePicker view1Ancestor = getTimePickerAncestor(view1); + TimePicker view2Ancestor = getTimePickerAncestor(view2); + return view1Ancestor == view2Ancestor; + } + + /* + * A generic version of this may come in handy as a library. Any {@link ViewGroup} view that + * supports Direct Manipulation mode will need something like this to ensure nudge actions + * don't result in navigating outside the parent {link ViewGroup} that is in Direct + * Manipulation mode. + */ + @Nullable + private static TimePicker getTimePickerAncestor(@Nullable View view) { + if (view instanceof TimePicker) { + return (TimePicker) view; + } + ViewParent viewParent = view.getParent(); + if (viewParent instanceof View) { + return getTimePickerAncestor((View) viewParent); + } + return null; + } + } + + /** + * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior + * for a {@link TimePicker}. + * <p> + * This handler expects that it is being used in Direct Manipulation mode, i.e. as a + * directional delegate through a {@link DirectManipulationHandler} which can invoke it at the + * appropriate times. + * <p> + * Only handles the following {@link KeyEvent}s and in the specified way below: + * <ul> + * <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - passes focus to a descendant view + * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - passes focus to a descendant view + * </ul> + * <p> + * When passing focus to a descendant, looks for all {@link NumberPicker} views and passes + * focus to the first one found. + * <p> + * This handler expects that any descendant {@link NumberPicker} objects have registered + * their own Direct Manipulation handlers via a {@link DirectManipulationHandler}. + */ + private static class TimePickerNudgeHandler + implements View.OnKeyListener { + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + if (!(view instanceof TimePicker)) { + return false; + } + boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + // TODO(pardis): if intending to reuse this for both time pickers, + // then need to make sure it can distinguish between the two. For clock + // we may need up and down. + // Disable by consuming the event and not doing anything. + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (isActionUp) { + TimePicker timePicker = (TimePicker) view; + List<NumberPicker> numberPickers = new ArrayList<>(); + getNumberPickerDescendants(numberPickers, timePicker); + if (numberPickers.isEmpty()) { + return false; + } + timePicker.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + numberPickers.get(0).requestFocus(); + } + return true; + default: + return false; + } + } + + } + + /* + * We don't have API access to the inner {@link View}s of a {@link TimePicker}. We do know based + * on {@code frameworks/base/core/res/res/layout/time_picker_legacy_material.xml} that a + * {@link TimePicker} that is in spinner mode will be using {@link NumberPicker}s internally, + * and that's what we rely on here. + */ + private static void getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v) { + for (int i = 0; i < v.getChildCount(); i++) { + View child = v.getChildAt(i); + if (child instanceof NumberPicker) { + numberPickers.add((NumberPicker) child); + } else if (child instanceof ViewGroup) { + getNumberPickerDescendants(numberPickers, (ViewGroup) child); + } + } + } } |