/* * Copyright (C) 2011 Google Inc. * * 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.talkback.contextmenu; import android.annotation.TargetApi; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.ColorFilter; import android.graphics.DashPathEffect; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Paint.Align; import android.graphics.Paint.Cap; import android.graphics.Paint.Style; import android.graphics.Path; import android.graphics.PixelFormat; import android.graphics.PointF; import android.graphics.PorterDuff; import android.graphics.PorterDuff.Mode; import android.graphics.PorterDuffColorFilter; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.GradientDrawable.Orientation; import android.support.annotation.NonNull; import android.support.v4.view.ViewCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.util.Log; import android.view.MotionEvent; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; import android.view.ViewConfiguration; import android.view.accessibility.AccessibilityEvent; import java.util.List; import com.android.talkback.R; import com.android.utils.ExploreByTouchObjectHelper; import com.android.utils.LogUtils; public class RadialMenuView extends SurfaceView { public enum SubMenuMode { /** Activate sub-menus by touching them for an extended time. */ LONG_PRESS, /** Activate sub-menus by touching them and lifting a finger. */ LIFT_TO_ACTIVATE } /** String used to ellipsize text. */ private static final String ELLIPSIS = "…"; /** The point at which the user started touching the menu. */ private final PointF mEntryPoint = new PointF(); /** Temporary matrix used for calculations. */ private final Matrix mTempMatrix = new Matrix(); // Cached bounds. private final RectF mCachedOuterBound = new RectF(); private final RectF mCachedCornerBound = new RectF(); private final RectF mCachedExtremeBound = new RectF(); // Cached paths representing a single item. private final Path mCachedOuterPath = new Path(); private final Path mCachedOuterPathReverse = new Path(); private final Path mCachedCornerPath = new Path(); private final Path mCachedCornerPathReverse = new Path(); // Cached widths for rendering text on paths. private float mCachedOuterPathWidth; private float mCachedCornerPathWidth; /** The root menu represented by this view. */ private final RadialMenu mRootMenu; /** The paint used to draw this view. */ private final Paint mPaint; /** The long-press handler for this view. */ private final LongPressHandler mHandler; /** The background drawn below the radial menu. */ private GradientDrawable mGradientBackground; /** The maximum radius allowable for a single-tap gesture. */ private final int mSingleTapRadiusSq; // Dimensions loaded from context. private final int mInnerRadius; private final int mOuterRadius; private final int mCornerRadius; private final int mExtremeRadius; private final int mSpacing; private final int mTextSize; private final int mTextShadowRadius; private final int mShadowRadius; // Generated from dimensions. private final int mInnerRadiusSq; private final int mExtremeRadiusSq; // Colors loaded from context. private final int mOuterFillColor; private final int mTextFillColor; private final int mCornerFillColor; private final int mCornerTextFillColor; private final int mDotFillColor; private final int mDotStrokeColor; private final int mSelectionColor; private final int mSelectionTextFillColor; private final int mSelectionShadowColor; private final int mCenterFillColor; private final int mCenterTextFillColor; private final int mTextShadowColor; private final DashPathEffect mDotPathEffect = new DashPathEffect(new float[] {20, 20}, 0); private static final float DOT_STROKE_WIDTH = 5; // Color filters for modal content. private final ColorFilter mSubMenuFilter; /** * Whether to use a node provider. If set to {@code false}, converts hover * events to touch events and does not send any accessibility events. */ private final boolean mUseNodeProvider; /** The current interaction mode for activating elements. */ private SubMenuMode mSubMenuMode = SubMenuMode.LIFT_TO_ACTIVATE; /** The surface holder onto which the view is drawn. */ private SurfaceHolder mHolder; /** The currently focused item. */ private RadialMenuItem mFocusedItem; /** The currently displayed sub-menu, if any. */ private RadialSubMenu mSubMenu; /** * The offset of the current sub-menu, in degrees. Used to align the * sub-menu with its parent menu item. */ private float mSubMenuOffset; /** * The offset of the root menu, in degrees. Used to align the first menu * item at the top or right of the radial menu. */ private float mRootMenuOffset = 0; /** The center point of the radial menu. */ private PointF mCenter = new PointF(); /** Whether to display the "carrot" dot. */ private boolean mDisplayWedges; /** Whether the current touch might be a single tap gesture. */ private boolean mMaybeSingleTap; public RadialMenuView(Context context, RadialMenu menu, boolean useNodeProvider) { super(context); mRootMenu = menu; mRootMenu.setLayoutListener(new RadialMenu.MenuLayoutListener() { @Override public void onLayoutChanged() { invalidate(); } }); mPaint = new Paint(); mPaint.setAntiAlias(true); mHandler = new LongPressHandler(context); mHandler.setListener(new LongPressHandler.LongPressListener() { @Override public void onLongPress() { onItemLongPressed(mFocusedItem); } }); final SurfaceHolder holder = getHolder(); holder.setFormat(PixelFormat.TRANSLUCENT); holder.addCallback(new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { mHolder = holder; } @Override public void surfaceDestroyed(SurfaceHolder holder) { mHolder = null; } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { invalidate(); } }); final Resources res = context.getResources(); final ViewConfiguration config = ViewConfiguration.get(context); mSingleTapRadiusSq = config.getScaledTouchSlop(); // Dimensions. mInnerRadius = res.getDimensionPixelSize(R.dimen.inner_radius); mOuterRadius = res.getDimensionPixelSize(R.dimen.outer_radius); mCornerRadius = res.getDimensionPixelSize(R.dimen.corner_radius); mExtremeRadius = res.getDimensionPixelSize(R.dimen.extreme_radius); mSpacing = res.getDimensionPixelOffset(R.dimen.spacing); mTextSize = res.getDimensionPixelSize(R.dimen.text_size); mTextShadowRadius = res.getDimensionPixelSize(R.dimen.text_shadow_radius); mShadowRadius = res.getDimensionPixelSize(R.dimen.shadow_radius); // Colors. mOuterFillColor = res.getColor(R.color.outer_fill); mTextFillColor = res.getColor(R.color.text_fill); mCornerFillColor = res.getColor(R.color.corner_fill); mCornerTextFillColor = res.getColor(R.color.corner_text_fill); mDotFillColor = res.getColor(R.color.dot_fill); mDotStrokeColor = res.getColor(R.color.dot_stroke); mSelectionColor = res.getColor(R.color.selection_fill); mSelectionTextFillColor = res.getColor(R.color.selection_text_fill); mSelectionShadowColor = res.getColor(R.color.selection_shadow); mCenterFillColor = res.getColor(R.color.center_fill); mCenterTextFillColor = res.getColor(R.color.center_text_fill); mTextShadowColor = res.getColor(R.color.text_shadow); // Gradient colors. final int gradientInnerColor = res.getColor(R.color.gradient_inner); final int gradientOuterColor = res.getColor(R.color.gradient_outer); final int[] colors = new int[] {gradientInnerColor, gradientOuterColor}; mGradientBackground = new GradientDrawable(Orientation.TOP_BOTTOM, colors); mGradientBackground.setGradientType(GradientDrawable.RADIAL_GRADIENT); mGradientBackground.setGradientRadius(mExtremeRadius * 2.0f); final int subMenuOverlayColor = res.getColor(R.color.submenu_overlay); // Lighting filters generated from colors. mSubMenuFilter = new PorterDuffColorFilter(subMenuOverlayColor, PorterDuff.Mode.SCREEN); mInnerRadiusSq = (mInnerRadius * mInnerRadius); mExtremeRadiusSq = (mExtremeRadius * mExtremeRadius); mUseNodeProvider = useNodeProvider; if (mUseNodeProvider) { // Lazily-constructed node provider helper. ViewCompat.setAccessibilityDelegate(this, new RadialMenuHelper(this)); } // Corner shapes only need to be invalidated and cached once. initializeCachedShapes(); } /** * Sets the sub-menu activation mode. * * @param subMenuMode A sub-menu activation mode. */ public void setSubMenuMode(SubMenuMode subMenuMode) { mSubMenuMode = subMenuMode; } /** * Displays a dot at the center of the screen and draws the corner menu * items. */ public void displayDot() { mDisplayWedges = false; mSubMenu = null; mFocusedItem = null; invalidate(); } /** * Displays the menu centered at the specified coordinates. * * @param centerX The center X coordinate. * @param centerY The center Y coordinate. */ public void displayAt(float centerX, float centerY) { mCenter.x = centerX; mCenter.y = centerY; mDisplayWedges = true; mSubMenu = null; mFocusedItem = null; invalidate(); } /** * Re-draw cached wedge bitmaps. */ private void invalidateCachedWedgeShapes() { final RadialMenu menu = mSubMenu != null ? mSubMenu : mRootMenu; final int menuSize = menu.size(); if (menuSize <= 0) { return; } final float wedgeArc = (360.0f / menuSize); final float offsetArc = ((wedgeArc / 2.0f) + 90.0f); final float spacingArc = (float) Math.toDegrees( Math.tan(mSpacing / (double) mOuterRadius)); final float left = (wedgeArc - spacingArc - offsetArc); final float center = ((wedgeArc / 2.0f) - offsetArc); final float right = (spacingArc - offsetArc); // Outer wedge. mCachedOuterPath.rewind(); mCachedOuterPath.arcTo(mCachedOuterBound, center, (left - center)); mCachedOuterPath.arcTo(mCachedExtremeBound, left, (right - left)); mCachedOuterPath.arcTo(mCachedOuterBound, right, (center - right)); mCachedOuterPath.close(); mCachedOuterPathWidth = arcLength((left - right), mExtremeRadius); // Outer wedge in reverse, for rendering text. mCachedOuterPathReverse.rewind(); mCachedOuterPathReverse.arcTo(mCachedOuterBound, center, (right - center)); mCachedOuterPathReverse.arcTo(mCachedExtremeBound, right, (left - right)); mCachedOuterPathReverse.arcTo(mCachedOuterBound, left, (center - left)); mCachedOuterPathReverse.close(); } /** * Initialized cached bounds and corner shapes. * <p> * <b>Note:</b> This method should be called whenever a radius value * changes. It must be called before any calls are made to * {@link #invalidateCachedWedgeShapes()}. */ private void initializeCachedShapes() { final int diameter = (mExtremeRadius * 2); createBounds(mCachedOuterBound, diameter, mOuterRadius); createBounds(mCachedCornerBound, diameter, mCornerRadius); createBounds(mCachedExtremeBound, diameter, mExtremeRadius); final float cornerWedgeArc = 90.0f; final float cornerOffsetArc = ((cornerWedgeArc / 2.0f) + 90.0f); final float cornerLeft = (cornerWedgeArc - cornerOffsetArc); final float cornerCenter = ((cornerWedgeArc / 2.0f) - cornerOffsetArc); final float cornerRight = -cornerOffsetArc; // Corner wedge. mCachedCornerPath.rewind(); mCachedCornerPath.arcTo(mCachedCornerBound, cornerCenter, (cornerLeft - cornerCenter)); mCachedCornerPath.arcTo(mCachedExtremeBound, cornerLeft, (cornerRight - cornerLeft)); mCachedCornerPath.arcTo(mCachedCornerBound, cornerRight, (cornerCenter - cornerRight)); mCachedCornerPath.close(); mCachedCornerPathWidth = arcLength((cornerLeft - cornerRight), mExtremeRadius); // Corner wedge in reverse, for rendering text. mCachedCornerPathReverse.rewind(); mCachedCornerPathReverse.arcTo( mCachedCornerBound, cornerCenter, (cornerRight - cornerCenter)); mCachedCornerPathReverse.arcTo( mCachedExtremeBound, cornerRight, (cornerLeft - cornerRight)); mCachedCornerPathReverse.arcTo(mCachedCornerBound, cornerLeft, (cornerCenter - cornerLeft)); mCachedCornerPathReverse.close(); } @Override public void invalidate() { super.invalidate(); final SurfaceHolder holder = mHolder; if (holder == null) { return; } final Canvas canvas = holder.lockCanvas(); if (canvas == null) { return; } // Clear the canvas. canvas.drawColor(Color.TRANSPARENT, Mode.CLEAR); if (getVisibility() != View.VISIBLE) { holder.unlockCanvasAndPost(canvas); return; } final int width = getWidth(); final int height = getHeight(); if (!mDisplayWedges) { mCenter.x = (width / 2.0f); mCenter.y = (height / 2.0f); } // Draw the pretty gradient background. mGradientBackground.setGradientCenter((mCenter.x / width), (mCenter.y / height)); mGradientBackground.setBounds(0, 0, width, height); mGradientBackground.draw(canvas); final RadialMenu menu = (mSubMenu != null) ? mSubMenu : mRootMenu; final float center = mExtremeRadius; if (mDisplayWedges) { final int wedges = menu.size(); final float degrees = 360.0f / wedges; // Refresh cached wedge shapes if necessary. if (0 != menu.size()) { invalidateCachedWedgeShapes(); } // Draw the cancel dot. drawCancel(canvas); // Draw wedges. for (int i = 0; i < wedges; i++) { drawWedge(canvas, center, i, menu, degrees); } } else { // Draw the center dot. drawCenterDot(canvas, width, height); } // Draw corners. for (int i = 0; i < 4; i++) { drawCorner(canvas, width, height, center, i); } holder.unlockCanvasAndPost(canvas); } private void drawCenterDot(Canvas canvas, int width, int height) { final float centerX = (width / 2.0f); final float centerY = (height / 2.0f); final float radius = mInnerRadius; final RectF dotBounds = new RectF( (centerX - radius), (centerY - radius), (centerX + radius), (centerY + radius)); mPaint.setStyle(Style.FILL); mPaint.setColor(mDotFillColor); mPaint.setShadowLayer(mTextShadowRadius, 0, 0, mTextShadowColor); canvas.drawOval(dotBounds, mPaint); mPaint.setShadowLayer(0, 0, 0, 0); contractBounds(dotBounds, (DOT_STROKE_WIDTH / 2)); mPaint.setStrokeWidth(DOT_STROKE_WIDTH); mPaint.setStyle(Style.STROKE); mPaint.setColor(mDotStrokeColor); mPaint.setPathEffect(mDotPathEffect); canvas.drawOval(dotBounds, mPaint); mPaint.setPathEffect(null); } private void drawCancel(Canvas canvas) { final float centerX = mCenter.x; final float centerY = mCenter.y; final float radius = mInnerRadius; final float iconRadius = (radius / 4.0f); final RectF dotBounds = new RectF( (centerX - radius), (centerY - radius), (centerX + radius), (centerY + radius)); // Apply the appropriate color filters. final boolean selected = (mFocusedItem == null); mPaint.setStyle(Style.FILL); mPaint.setColor(selected ? mSelectionColor : mCenterFillColor); mPaint.setShadowLayer(mShadowRadius, 0, 0, (selected ? mSelectionShadowColor : mTextShadowColor)); canvas.drawOval(dotBounds, mPaint); mPaint.setShadowLayer(0, 0, 0, 0); mPaint.setStyle(Style.STROKE); mPaint.setColor(selected ? mSelectionTextFillColor : mCenterTextFillColor); mPaint.setStrokeCap(Cap.SQUARE); mPaint.setStrokeWidth(10.0f); canvas.drawLine((centerX - iconRadius), (centerY - iconRadius), (centerX + iconRadius), (centerY + iconRadius), mPaint); canvas.drawLine((centerX + iconRadius), (centerY - iconRadius), (centerX - iconRadius), (centerY + iconRadius), mPaint); } private void drawWedge(Canvas canvas, float center, int i, RadialMenu menu, float degrees) { final float offset = mSubMenu != null ? mSubMenuOffset : mRootMenuOffset; final RadialMenuItem wedge = menu.getItem(i); final String title = wedge.getTitle().toString(); final float rotation = ((degrees * i) + offset); final boolean selected = wedge.equals(mFocusedItem); // Apply the appropriate color filters. if (wedge.hasSubMenu()) { mPaint.setColorFilter(mSubMenuFilter); } else { mPaint.setColorFilter(null); } wedge.offset = rotation; mTempMatrix.reset(); mTempMatrix.setRotate(rotation, center, center); mTempMatrix.postTranslate((mCenter.x - center), (mCenter.y - center)); canvas.setMatrix(mTempMatrix); mPaint.setStyle(Style.FILL); mPaint.setColor(selected ? mSelectionColor : mOuterFillColor); mPaint.setShadowLayer(mShadowRadius, 0, 0, (selected ? mSelectionShadowColor : mTextShadowColor)); canvas.drawPath(mCachedOuterPath, mPaint); mPaint.setShadowLayer(0, 0, 0, 0); mPaint.setStyle(Style.FILL); mPaint.setColor(selected ? mSelectionTextFillColor : mTextFillColor); mPaint.setTextAlign(Align.CENTER); mPaint.setTextSize(mTextSize); mPaint.setShadowLayer(mTextShadowRadius, 0, 0, mTextShadowColor); final String renderText = getEllipsizedText(mPaint, title, mCachedOuterPathWidth); // Orient text differently depending on the angle. if ((rotation < 90) || (rotation > 270)) { canvas.drawTextOnPath(renderText, mCachedOuterPathReverse, 0, (2 * mTextSize), mPaint); } else { canvas.drawTextOnPath(renderText, mCachedOuterPath, 0, -mTextSize, mPaint); } mPaint.setShadowLayer(0, 0, 0, 0); mPaint.setColorFilter(null); } private void drawCorner(Canvas canvas, int width, int height, float center, int i) { final RadialMenuItem wedge = mRootMenu.getCorner(i); if (wedge == null || !wedge.isVisible()) { return; } final float rotation = RadialMenu.getCornerRotation(i); final PointF cornerLocation = RadialMenu.getCornerLocation(i); if (cornerLocation == null) return; final float cornerX = (cornerLocation.x * width); final float cornerY = (cornerLocation.y * height); final String title = wedge.getTitle().toString(); final boolean selected = wedge.equals(mFocusedItem); // Apply the appropriate color filters. if (wedge.hasSubMenu()) { mPaint.setColorFilter(mSubMenuFilter); } else { mPaint.setColorFilter(null); } wedge.offset = rotation; mTempMatrix.reset(); mTempMatrix.setRotate(rotation, center, center); mTempMatrix.postTranslate((cornerX - center), (cornerY - center)); canvas.setMatrix(mTempMatrix); mPaint.setStyle(Style.FILL); mPaint.setColor(selected ? mSelectionColor : mCornerFillColor); mPaint.setShadowLayer(mShadowRadius, 0, 0, (selected ? mSelectionShadowColor : mTextShadowColor)); canvas.drawPath(mCachedCornerPath, mPaint); mPaint.setShadowLayer(0, 0, 0, 0); mPaint.setStyle(Style.FILL); mPaint.setColor(selected ? mSelectionTextFillColor : mCornerTextFillColor); mPaint.setTextAlign(Align.CENTER); mPaint.setTextSize(mTextSize); mPaint.setShadowLayer(mTextShadowRadius, 0, 0, mTextShadowColor); final String renderText = getEllipsizedText(mPaint, title, mCachedCornerPathWidth); // Orient text differently depending on the angle. if (((rotation < 90) && (rotation > -90)) || (rotation > 270)) { canvas.drawTextOnPath(renderText, mCachedCornerPathReverse, 0, (2 * mTextSize), mPaint); } else { canvas.drawTextOnPath(renderText, mCachedCornerPath, 0, -mTextSize, mPaint); } mPaint.setShadowLayer(0, 0, 0, 0); mPaint.setColorFilter(null); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int widthMode = MeasureSpec.getMode(widthMeasureSpec); final int widthSize = MeasureSpec.getSize(widthMeasureSpec); final int heightMode = MeasureSpec.getMode(heightMeasureSpec); final int heightSize = MeasureSpec.getSize(heightMeasureSpec); final int measuredWidth = (widthMode == MeasureSpec.UNSPECIFIED) ? 320 : widthSize; final int measuredHeight = (heightMode == MeasureSpec.UNSPECIFIED) ? 480 : heightSize; setMeasuredDimension(measuredWidth, measuredHeight); } @TargetApi(14) @Override public boolean onHoverEvent(@NonNull MotionEvent event) { if (mUseNodeProvider) { return super.onHoverEvent(event); } else { return onTouchEvent(event); } } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_HOVER_ENTER: // Fall-through to movement events. onEnter(event.getX(), event.getY()); case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_HOVER_MOVE: onMove(event.getX(), event.getY()); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_HOVER_EXIT: onUp(event.getX(), event.getY()); break; default: // Don't handle other types of events. return false; } mHandler.onTouch(this, event); return true; } /** * Computes and returns which menu item or corner the user is touching. * * @param x The touch X coordinate. * @param y The touch Y coordinate. * @return A pair containing the menu item that the user is touching and * whether the user is touching the menu item directly. */ private TouchedMenuItem getTouchedMenuItem(float x, float y) { final TouchedMenuItem result = new TouchedMenuItem(); final float dX = (x - mCenter.x); final float dY = (y - mCenter.y); final float touchDistSq = (dX * dX) + (dY * dY); if (getClosestTouchedCorner(x, y, result)) { // First preference goes to corners. } else if (mDisplayWedges && getClosestTouchedWedge(dX, dY, touchDistSq, result)) { // Second preference goes to wedges, if displayed. } return result; } private boolean getClosestTouchedCorner(float x, float y, TouchedMenuItem result) { final int width = getWidth(); final int height = getHeight(); // How close is the user to a corner? for (int groupId = 0; groupId < 4; groupId++) { final RadialMenuItem corner = mRootMenu.getCorner(groupId); if (corner == null) { continue; } final PointF cornerLocation = RadialMenu.getCornerLocation(groupId); if (cornerLocation == null) continue; final float cornerDX = (x - (cornerLocation.x * width)); final float cornerDY = (y - (cornerLocation.y * height)); final float cornerTouchDistSq = (cornerDX * cornerDX) + (cornerDY * cornerDY); // If the user is touching within a corner's outer radius, consider // it a direct touch. if (cornerTouchDistSq < mExtremeRadiusSq) { result.item = corner; result.isDirectTouch = true; return true; } } return false; } private boolean getClosestTouchedWedge( float dX, float dY, float touchDistSq, TouchedMenuItem result) { if (touchDistSq <= mInnerRadiusSq) { // The user is touching the center dot. return false; } final RadialMenu menu = (mSubMenu != null) ? mSubMenu : mRootMenu; int menuSize = menu.size(); if (menuSize == 0) return false; final float offset = (mSubMenu != null) ? mSubMenuOffset : mRootMenuOffset; // Which wedge is the user touching? final double angle = Math.atan2(dX, dY); final double wedgeArc = (360.0 / menuSize); final double offsetArc = (wedgeArc / 2.0) - offset; double touchArc = (((180.0 - Math.toDegrees(angle)) + offsetArc) % 360); if (touchArc < 0) { touchArc += 360; } final int wedgeNum = (int) (touchArc / wedgeArc); if ((wedgeNum < 0) || (wedgeNum >= menuSize)) { LogUtils.log(this, Log.ERROR, "Invalid wedge index: %d", wedgeNum); return false; } result.item = menu.getItem(wedgeNum); result.isDirectTouch = (touchDistSq < mExtremeRadiusSq); return true; } /** * Called when the user's finger first touches the radial menu. * * @param x The touch X coordinate. * @param y The touch Y coordinate. */ private void onEnter(float x, float y) { mEntryPoint.set(x, y); mMaybeSingleTap = true; } /** * Called when the user moves their finger. Focuses a menu item. * * @param x The touch X coordinate. * @param y The touch Y coordinate. */ private void onMove(float x, float y) { if (mMaybeSingleTap && (distSq(mEntryPoint, x, y) >= mSingleTapRadiusSq)) { mMaybeSingleTap = false; } final TouchedMenuItem touchedItem = getTouchedMenuItem(x, y); // Only focus the item if this is definitely not a single tap or the // user is directly touching a menu item. if (!mMaybeSingleTap || (touchedItem.item == null) || touchedItem.isDirectTouch) { onItemFocused(touchedItem.item); } // Only display the wedges if we didn't just place focus on an item. if ((touchedItem.item == null) && !mDisplayWedges) { mDisplayWedges = true; displayAt(x, y); } } /** * Called when the user lifts their finger. Selects a menu item. * * @param x The touch X coordinate. * @param y The touch Y coordinate. */ private void onUp(float x, float y) { final TouchedMenuItem touchedItem = getTouchedMenuItem(x, y); // Only select the item if this is definitely not a single tap or the // user is directly touching a menu item. if (!mMaybeSingleTap || (touchedItem.item == null) || touchedItem.isDirectTouch) { onItemSelected(touchedItem.item); } } /** * Sets a sub-menu as the currently displayed menu. * * @param subMenu The sub-menu to display. * @param offset The offset of the sub-menu's menu item. */ private void setSubMenu(RadialSubMenu subMenu, float offset) { mSubMenu = subMenu; mSubMenuOffset = offset; invalidateCachedWedgeShapes(); invalidate(); subMenu.onShow(); if ((subMenu != null) && (subMenu.size() > 0) && (mSubMenuMode == SubMenuMode.LONG_PRESS)) { onItemFocused(subMenu.getItem(0)); } } /** * Called when an item is focused. If the newly focused item is the same as * the previously focused item, this is a no-op. Otherwise, the menu item's * select action is triggered and an accessibility select event is fired. * * @param item The item that the user focused. */ private void onItemFocused(RadialMenuItem item) { if (mFocusedItem == item) { return; } final RadialMenu menu = (mSubMenu != null) ? mSubMenu : mRootMenu; mFocusedItem = item; invalidate(); if (item == null) { menu.clearSelection(0); } else if (item.isCorner()) { // Currently only the root menu is allowed to have corners. mRootMenu.selectMenuItem(item, 0); } else { menu.selectMenuItem(item, 0); } sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); } /** * Called when the user stops over an item. If the user stops over the * "no selection" area and the current menu is a sub-menu, the sub-menu * closes. If the user stops over a sub-menu item, that sub-menu opens. * * @param item The menu item that the user stopped over. */ private void onItemLongPressed(RadialMenuItem item) { if (mSubMenuMode == SubMenuMode.LONG_PRESS) { if (item != null) { if (item.hasSubMenu()) { setSubMenu(item.getSubMenu(), item.offset); } } else if (mSubMenu != null) { // Switch back to the root menu. setSubMenu(null, 0); } } } /** * Called when a menu item is selected. The menu item's perform action is * triggered and a click accessibility event is fired. * * @param item The item that the used selected. */ private void onItemSelected(RadialMenuItem item) { final RadialMenu menu = (mSubMenu != null) ? mSubMenu : mRootMenu; mFocusedItem = item; invalidate(); if (item == null) { menu.performMenuItem(null, 0); } else if (item.hasSubMenu()) { setSubMenu(item.getSubMenu(), item.offset); // TODO: Refactor such that the identifier action for an item with a // sub-menu is to simply open the sub-menu. Currently only the view // (this class) can manipulate sub-menus. if (item.isCorner()) { // Currently only the root menu is allowed to have corners. mRootMenu.performMenuItem(item, RadialMenu.FLAG_PERFORM_NO_CLOSE); } else { menu.performMenuItem(item, RadialMenu.FLAG_PERFORM_NO_CLOSE); } } else { if (item.isCorner()) { // Currently only the root menu is allowed to have corners. mRootMenu.performMenuItem(item, 0); } else { menu.performMenuItem(item, 0); } } sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED); } private static String getEllipsizedText(Paint paint, String title, float maxWidth) { final float textWidth = paint.measureText(title); if (textWidth <= maxWidth) { return title; } // Find the maximum length with an ellipsis. final float ellipsisWidth = paint.measureText(ELLIPSIS); final int length = paint.breakText(title, true, (maxWidth - ellipsisWidth), null); // Try to land on a word break. // TODO: Use breaking iterator for better i18n support. final int space = title.lastIndexOf(' ', length); if (space > 0) { return title.substring(0, space) + ELLIPSIS; } // Otherwise, cut off characters. return title.substring(0, length) + ELLIPSIS; } @SuppressWarnings("SuspiciousNameCombination") private static void createBounds(RectF target, int diameter, int radius) { final float center = (diameter / 2.0f); final float left = (center - radius); final float right = (center + radius); target.set(left, left, right, right); } private static void contractBounds(RectF rect, float amount) { rect.left += amount; rect.top += amount; rect.right -= amount; rect.bottom -= amount; } /** * Computes the squared distance between a point and an (x,y) coordinate. * * @param p The point. * @param x The x-coordinate. * @param y The y-coordinate. * @return The squared distance between the point and (x,y) coordinate. */ private static float distSq(PointF p, float x, float y) { final float dX = (x - p.x); final float dY = (y - p.y); return ((dX * dX) + (dY * dY)); } /** * Computes the length of an arc defined by an angle and radius. * * @param angle The angle of the arc, in degrees. * @param radius The radius of the arc. * @return The length of the arc. */ private static float arcLength(float angle, float radius) { return ((2.0f * (float) Math.PI * radius) * (angle / 360.0f)); } private static class TouchedMenuItem { private RadialMenuItem item = null; private boolean isDirectTouch = false; } private class RadialMenuHelper extends ExploreByTouchObjectHelper<RadialMenuItem> { private final Rect mTempRect = new Rect(); public RadialMenuHelper(View parentView) { super(parentView); } @Override protected void populateNodeForItem(RadialMenuItem item, AccessibilityNodeInfoCompat node) { node.setContentDescription(item.getTitle()); node.setVisibleToUser(item.isVisible()); node.setCheckable(item.isCheckable()); node.setChecked(item.isChecked()); node.setEnabled(item.isEnabled()); node.setClickable(true); getLocalVisibleRect(mTempRect); node.setBoundsInParent(mTempRect); getGlobalVisibleRect(mTempRect); node.setBoundsInScreen(mTempRect); } @Override protected void populateEventForItem(RadialMenuItem item, AccessibilityEvent event) { event.setContentDescription(item.getTitle()); event.setChecked(item.isChecked()); event.setEnabled(item.isEnabled()); } @Override protected boolean performActionForItem(RadialMenuItem item, int action) { switch (action) { case AccessibilityNodeInfoCompat.ACTION_CLICK: item.onClickPerformed(); return true; } return false; } @Override protected RadialMenuItem getItemForVirtualViewId(int id) { return mRootMenu.getItem(id); } @Override protected int getVirtualViewIdForItem(RadialMenuItem item) { return mRootMenu.indexOf(item); } @Override protected RadialMenuItem getItemAt(float x, float y) { return getTouchedMenuItem(x, y).item; } @Override protected void getVisibleItems(List<RadialMenuItem> items) { for (int i = 0; i < mRootMenu.size(); i++) { final RadialMenuItem item = mRootMenu.getItem(i); items.add(item); } } } }