pfthomas 1ee07687af [BottomSheet] Make handle respond to keyboard input
PiperOrigin-RevId: 775207544
2025-06-27 16:09:03 +00:00

391 lines
14 KiB
Java

/*
* Copyright 2022 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.google.android.material.bottomsheet;
import com.google.android.material.R;
import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_CLICKED;
import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import androidx.appcompat.widget.AppCompatImageView;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.GestureDetector.OnGestureListener;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback;
/**
* A drag handle view that can be added to bottom sheets associated with {@link
* BottomSheetBehavior}. This view will automatically handle the accessibility interaction when the
* accessibility service is enabled. When you add a drag handle to a bottom sheet and the user
* enables the accessibility service, the drag handle will become important for accessibility and
* clickable. Clicking the drag handle will toggle the bottom sheet between its collapsed,
* half expanded, and expanded states.
*/
public class BottomSheetDragHandleView extends AppCompatImageView implements
AccessibilityStateChangeListener {
private static final int DEF_STYLE_RES = R.style.Widget_Material3_BottomSheet_DragHandle;
@Nullable private final AccessibilityManager accessibilityManager;
@Nullable private BottomSheetBehavior<?> bottomSheetBehavior;
private final GestureDetector gestureDetector;
private boolean clickToExpand;
/**
* Track whether clients have set their own touch or click listeners on the drag handle.
*
* Setting a custom touch or click listener will override the default behavior of cycling through
* bottom sheet states when tapped and dismissing the sheet when double tapped. Clients can
* restore this behavior by setting their touch and click listeners back to null.
*/
private boolean hasTouchListener = false;
private boolean hasClickListener = false;
private final String clickToExpandActionLabel =
getResources().getString(R.string.bottomsheet_action_expand_description);
private final String clickToHalfExpandActionLabel =
getResources().getString(R.string.bottomsheet_action_half_expand_description);
private final String clickToCollapseActionLabel =
getResources().getString(R.string.bottomsheet_action_collapse_description);
private final BottomSheetCallback bottomSheetCallback =
new BottomSheetCallback() {
@Override
public void onStateChanged(
@NonNull View bottomSheet, @BottomSheetBehavior.State int newState) {
onBottomSheetStateChanged(newState);
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
};
/**
* A gesture listener that handles both single and double taps on the drag handle.
*
* Single taps cycle through the available states of the bottom sheet. A double tap hides
* the sheet.
*/
private final OnGestureListener gestureListener = new SimpleOnGestureListener() {
@Override
public boolean onDown(@NonNull MotionEvent e) {
return isClickable();
}
@Override
public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
return expandOrCollapseBottomSheetIfPossible();
}
@Override
public boolean onDoubleTap(@NonNull MotionEvent e) {
if (bottomSheetBehavior != null && bottomSheetBehavior.isHideable()) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
return true;
}
return super.onDoubleTap(e);
}
};
public BottomSheetDragHandleView(@NonNull Context context) {
this(context, /* attrs= */ null);
}
public BottomSheetDragHandleView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.bottomSheetDragHandleStyle);
}
@SuppressLint("ClickableViewAccessibility") // Will be handled by accessibility delegate
public BottomSheetDragHandleView(
@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr);
// Override the provided context with the wrapped one to prevent it from being used.
context = getContext();
gestureDetector =
new GestureDetector(context, gestureListener, new Handler(Looper.getMainLooper()));
accessibilityManager =
(AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
ViewCompat.setAccessibilityDelegate(
this,
new AccessibilityDelegateCompat() {
@Override
public void onPopulateAccessibilityEvent(View host, @NonNull AccessibilityEvent event) {
super.onPopulateAccessibilityEvent(host, event);
if (event.getEventType() == TYPE_VIEW_CLICKED) {
expandOrCollapseBottomSheetIfPossible();
}
}
@Override
public void onInitializeAccessibilityNodeInfo(
@NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
if (!hasAttachedBehavior()) {
return;
}
CharSequence originalDescription = getContentDescription();
String stateName = null;
switch (bottomSheetBehavior.getState()) {
case BottomSheetBehavior.STATE_COLLAPSED:
stateName = getResources().getString(R.string.bottomsheet_state_collapsed);
break;
case BottomSheetBehavior.STATE_EXPANDED:
stateName = getResources().getString(R.string.bottomsheet_state_expanded);
break;
case BottomSheetBehavior.STATE_HALF_EXPANDED:
stateName = getResources().getString(R.string.bottomsheet_state_half_expanded);
break;
default: // fall out
}
if (!TextUtils.isEmpty(stateName)) {
CharSequence newDescription =
TextUtils.isEmpty(originalDescription)
? stateName
: stateName + ". " + originalDescription;
info.setContentDescription(newDescription);
}
}
});
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
setBottomSheetBehavior(findParentBottomSheetBehavior());
if (accessibilityManager != null) {
accessibilityManager.addAccessibilityStateChangeListener(this);
onAccessibilityStateChanged(accessibilityManager.isEnabled());
}
}
@Override
protected void onDetachedFromWindow() {
if (accessibilityManager != null) {
accessibilityManager.removeAccessibilityStateChangeListener(this);
}
setBottomSheetBehavior(null);
super.onDetachedFromWindow();
}
@SuppressLint("ClickableViewAccessibility") // Will be handled by accessibility delegate
@Override
public boolean onTouchEvent(MotionEvent event) {
if (hasClickListener || hasTouchListener) {
// If clients have set their own click or touch listeners, do nothing.
return super.onTouchEvent(event);
}
return gestureDetector.onTouchEvent(event);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void setOnTouchListener(OnTouchListener l) {
hasTouchListener = l != null;
super.setOnTouchListener(l);
}
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
hasClickListener = l != null;
super.setOnClickListener(l);
}
@Override
public void onAccessibilityStateChanged(boolean enabled) {
// Do nothing.
}
private void setBottomSheetBehavior(@Nullable BottomSheetBehavior<?> behavior) {
if (bottomSheetBehavior != null) {
bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback);
bottomSheetBehavior.setAccessibilityDelegateView(null);
bottomSheetBehavior.setDragHandleView(null);
}
bottomSheetBehavior = behavior;
if (bottomSheetBehavior != null) {
bottomSheetBehavior.setAccessibilityDelegateView(this);
bottomSheetBehavior.setDragHandleView(this);
onBottomSheetStateChanged(bottomSheetBehavior.getState());
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback);
}
setClickable(hasAttachedBehavior());
}
private void onBottomSheetStateChanged(@BottomSheetBehavior.State int state) {
if (state == BottomSheetBehavior.STATE_COLLAPSED) {
clickToExpand = true;
} else if (state == BottomSheetBehavior.STATE_EXPANDED) {
clickToExpand = false;
} // Else keep the original settings
int nextState = getNextState();
String text = null;
switch (nextState) {
case BottomSheetBehavior.STATE_COLLAPSED:
text = clickToCollapseActionLabel;
break;
case BottomSheetBehavior.STATE_EXPANDED:
text = clickToExpandActionLabel;
break;
case BottomSheetBehavior.STATE_HALF_EXPANDED:
text = clickToHalfExpandActionLabel;
break;
default: // fall out
}
ViewCompat.replaceAccessibilityAction(
this,
AccessibilityActionCompat.ACTION_CLICK,
text,
(v, args) -> expandOrCollapseBottomSheetIfPossible());
}
private boolean hasAttachedBehavior() {
return bottomSheetBehavior != null;
}
/**
* Expands or collapses the associated bottom sheet according to the current state and the
* previous state when the drag handle is interactable, .
*
* <p>If the current state is COLLAPSED or EXPANDED and the bottom sheet can be half-expanded, it
* will make the bottom sheet HALF_EXPANDED; if the bottom sheet cannot be half-expanded, it will
* be EXPANDED (when it's COLLAPSED) or COLLAPSED (when it's EXPANDED) instead. On the other hand
* when the bottom sheet is HALF_EXPANDED, it will make the bottom sheet either COLLAPSED (when
* the previous state was EXPANDED) or EXPANDED (when the previous state was COLLAPSED.)
*/
private boolean expandOrCollapseBottomSheetIfPossible() {
if (!hasAttachedBehavior()) {
return false;
}
int nextState = getNextState();
if (nextState != -1) {
bottomSheetBehavior.setState(nextState);
}
return true;
}
private int getNextState() {
int nextState = -1;
if (!hasAttachedBehavior()) {
return nextState;
}
boolean canHalfExpand =
!bottomSheetBehavior.isFitToContents()
&& !bottomSheetBehavior.shouldSkipHalfExpandedStateWhenDragging();
int currentState = bottomSheetBehavior.getState();
switch (currentState) {
case BottomSheetBehavior.STATE_COLLAPSED:
nextState =
canHalfExpand
? BottomSheetBehavior.STATE_HALF_EXPANDED
: BottomSheetBehavior.STATE_EXPANDED;
break;
case BottomSheetBehavior.STATE_EXPANDED:
nextState =
canHalfExpand
? BottomSheetBehavior.STATE_HALF_EXPANDED
: BottomSheetBehavior.STATE_COLLAPSED;
break;
case BottomSheetBehavior.STATE_HALF_EXPANDED:
nextState =
clickToExpand
? BottomSheetBehavior.STATE_EXPANDED
: BottomSheetBehavior.STATE_COLLAPSED;
break;
default: // fall out
}
return nextState;
}
/**
* Finds the first ancestor associated with a {@link BottomSheetBehavior}. If none is found,
* returns {@code null}.
*/
@Nullable
private BottomSheetBehavior<?> findParentBottomSheetBehavior() {
View parent = this;
while ((parent = getParentView(parent)) != null) {
LayoutParams layoutParams = parent.getLayoutParams();
if (layoutParams instanceof CoordinatorLayout.LayoutParams) {
CoordinatorLayout.Behavior<?> behavior =
((CoordinatorLayout.LayoutParams) layoutParams).getBehavior();
if (behavior instanceof BottomSheetBehavior) {
return (BottomSheetBehavior<?>) behavior;
}
}
}
return null;
}
@Nullable
private static View getParentView(View view) {
ViewParent parent = view.getParent();
return parent instanceof View ? (View) parent : null;
}
@Override
public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
if (!isEnabled()) {
return super.onKeyDown(keyCode, event);
}
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
if (hasClickListener) {
return performClick();
}
return expandOrCollapseBottomSheetIfPossible();
default:
// Nothing to do in this case.
}
return super.onKeyDown(keyCode, event);
}
}