public final class

ToolbarController

extends java.lang.Object

 java.lang.Object

↳androidx.textclassifier.widget.ToolbarController

Gradle dependencies

compile group: 'androidx.textclassifier', name: 'textclassifier', version: '1.0.0-alpha04'

  • groupId: androidx.textclassifier
  • artifactId: textclassifier
  • version: 1.0.0-alpha04

Artifact androidx.textclassifier:textclassifier:1.0.0-alpha04 it located at Google repository (https://maven.google.com/)

Androidx artifact mapping:

androidx.textclassifier:textclassifier com.android.support:textclassifier

Overview

Controls displaying of actions in the floating toolbar.

Summary

Methods
public static ToolbarControllergetInstance(TextView textView)

Returns the singleton instance of the toolbar controller and associates it with the specified textView.

public static voidsetFloatingToolbarFactory(ToolbarController.FloatingToolbarFactory floatingToolbarFactory)

Sets a factory that creates an instance of floating toolbar.

public voidshow(java.util.List<RemoteActionCompat> actions, int start, int end)

Shows the floating toolbar with the specified actions.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public static ToolbarController getInstance(TextView textView)

Returns the singleton instance of the toolbar controller and associates it with the specified textView. If the toolbar was initially associated with a different textView, the toolbar will be dismissed before associating it with the newly specified textView.

public static void setFloatingToolbarFactory(ToolbarController.FloatingToolbarFactory floatingToolbarFactory)

Sets a factory that creates an instance of floating toolbar.

public void show(java.util.List<RemoteActionCompat> actions, int start, int end)

Shows the floating toolbar with the specified actions.

This controller also adds standard items (e.g. Copy, Share) to the toolbar in addition to the specified actions.

Parameters:

actions: actions to show in the toolbar
start: text start index for positioning the toolbar; must be less at least 0 and less than end index
end: text end index for positioning the toolbar; the toolbar will not be shown this index is invalid for the associated textView

Source

/*
 * Copyright (C) 2018 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 androidx.textclassifier.widget;

import static androidx.annotation.RestrictTo.Scope.LIBRARY;

import android.app.PendingIntent;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.Build;
import android.text.Layout;
import android.text.Spannable;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.CharacterStyle;
import android.util.Log;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.PopupWindow;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.view.menu.MenuBuilder;
import androidx.collection.ArrayMap;
import androidx.core.app.RemoteActionCompat;
import androidx.core.internal.view.SupportMenu;
import androidx.core.util.Preconditions;
import androidx.textclassifier.R;

import java.lang.ref.WeakReference;
import java.util.List;
import java.util.Map;

/**
 * Controls displaying of actions in the floating toolbar.
 *
 * @hide
 */
@RestrictTo(LIBRARY)
@RequiresApi(Build.VERSION_CODES.M)
@UiThread
public final class ToolbarController {

    private static final String LOG_TAG = "ToolbarController";
    private static final int ORDER_START = 50;
    private static final int ALPHA = 20;
    private static final int HIGHLIGHT_DELAY_MS = 80;

    private final TextView mTextView;
    private final Rect mContentRect;
    private final IFloatingToolbar mToolbar;
    private final BackgroundSpan mHighlight;

    private static WeakReference<ToolbarController> sInstance = new WeakReference<>(null);
    private static FloatingToolbarFactory sFloatingToolbarFactory =
            textView -> new FloatingToolbar(textView);

    /**
     * Returns the singleton instance of the toolbar controller and associates it with the specified
     * textView. If the toolbar was initially associated with a different textView, the toolbar will
     * be dismissed before associating it with the newly specified textView.
     */
    public static ToolbarController getInstance(TextView textView) {
        final ToolbarController controller = sInstance.get();
        if (controller == null) {
            sInstance = new WeakReference<>(new ToolbarController(textView));
        } else if (controller.mTextView != textView) {
            logv("New textView. Dismissing previous toolbar.");
            dismissImmediately(controller.mToolbar);
            sInstance = new WeakReference<>(new ToolbarController(textView));
        }
        return sInstance.get();
    }

    private ToolbarController(TextView textView) {
        mTextView = Preconditions.checkNotNull(textView);
        mContentRect = new Rect();
        mHighlight = new BackgroundSpan(withAlpha(mTextView.getHighlightColor()));
        mToolbar = sFloatingToolbarFactory.create(textView);
        mToolbar.setOnMenuItemClickListener(new OnMenuItemClickListener(mToolbar));
        mToolbar.setDismissOnMenuItemClick(true);
    }

    /**
     * Sets a factory that creates an instance of floating toolbar.
     */
    public static void setFloatingToolbarFactory(
            @NonNull FloatingToolbarFactory floatingToolbarFactory) {
        sFloatingToolbarFactory = Preconditions.checkNotNull(floatingToolbarFactory);
    }

    /**
     * Shows the floating toolbar with the specified actions.
     *
     * <p>This controller also adds standard items (e.g. Copy, Share) to the toolbar in addition to
     * the specified actions.
     *
     * @param actions actions to show in the toolbar
     * @param start   text start index for positioning the toolbar;
     *                must be less at least 0 and less than end index
     * @param end     text end index for positioning the toolbar;
     *                the toolbar will not be shown this index is invalid for the associated
     *                textView
     */
    public void show(List<RemoteActionCompat> actions, int start, int end) {
        Preconditions.checkNotNull(actions);
        Preconditions.checkArgumentInRange(start, 0, end - 1, "start");

        final CharSequence text = mTextView.getText();
        if (text == null || end > text.length()) {
            Log.d(LOG_TAG, "Cannot show link toolbar. Invalid text indices");
            return;
        }

        logv("About to show new toolbar state. Dismissing old state");
        dismissImmediately(mToolbar);
        final SupportMenu menu = createMenu(mTextView, mHighlight, actions);
        if (canShowToolbar(mTextView, true) && menu.hasVisibleItems()) {
            setListeners(mTextView, start, end, mToolbar);
            setHighlight(mTextView, mHighlight, start, end, mToolbar);
            updateRectCoordinates(mContentRect, mTextView, start, end);
            mToolbar.setContentRect(mContentRect);
            mToolbar.setMenu(menu);
            mToolbar.show();
            logv("Showing toolbar");
        }
    }

    @VisibleForTesting
    boolean isToolbarShowing() {
        return mToolbar.isShowing();
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void dismissImmediately(IFloatingToolbar toolbar) {
        toolbar.hide();
        toolbar.dismiss();
    }

    /**
     * Returns true if the textView should be allowed to show a toolbar. Otherwise, returns false.
     *
     * @param textView          the textView
     * @param assumeWindowFocus if true, this method assumes the window in which the textView is in
     *                          has focus. Should typically be set to {@code true} unless the caller
     *                          knows the window does not have focus.
     */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static boolean canShowToolbar(TextView textView, boolean assumeWindowFocus) {
        final boolean viewFocus = textView.hasFocus();
        final boolean viewAttached = textView.isAttachedToWindow();
        final boolean canShowToolbar = assumeWindowFocus && viewFocus && viewAttached;
        if (!canShowToolbar) {
            logv(String.format("canShowToolbar=false. "
                            + "Reason: windowFocus=%b, viewFocus=%b, viewAttached=%b",
                    assumeWindowFocus, viewFocus, viewAttached));
        }
        return canShowToolbar;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static int withAlpha(int color) {
        return Color.argb(ALPHA, Color.red(color), Color.green(color), Color.blue(color));
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    @Nullable
    static String getHighlightedText(TextView textView, BackgroundSpan highlight) {
        final CharSequence text = textView.getText();
        if (text instanceof Spannable) {
            final Spannable spannable = (Spannable) text;
            final int start = spannable.getSpanStart(highlight);
            final int end = spannable.getSpanEnd(highlight);
            final int min = Math.max(0, Math.min(start, end));
            final int max = Math.max(0, Math.max(start, end));
            return textView.getText().subSequence(min, max).toString();
        }
        return null;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void removeHighlight(TextView textView) {
        final CharSequence text = textView.getText();
        if (text instanceof Spannable) {
            final Spannable spannable = (Spannable) text;
            final BackgroundSpan[] spans =
                    spannable.getSpans(0, text.length(), BackgroundSpan.class);
            for (BackgroundSpan span : spans) {
                spannable.removeSpan(span);
            }
        }
    }

    private static void setHighlight(
            final TextView textView, final BackgroundSpan highlight,
            final int start, final int end, final IFloatingToolbar toolbar) {
        final CharSequence text = textView.getText();
        if (text instanceof Spannable) {
            removeHighlight(textView);
            final String originalText = text.toString();
            textView.postDelayed(new Runnable() {
                @Override
                public void run() {
                    if (canShowToolbar(textView, true)
                            && originalText.equals(textView.getText().toString())
                            && toolbar.isShowing()) {
                        ((Spannable) text).setSpan(highlight, start, end, 0);
                    }
                }
            }, HIGHLIGHT_DELAY_MS);
        }
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void updateRectCoordinates(Rect rect, TextView textView, int startIndex, int endIndex) {
        final int[] startXY = getCoordinates(textView, startIndex, /* startCoordinate= */ true);
        final int[] endXY = getCoordinates(textView, endIndex, /* startCoordinate= */false);
        rect.set(startXY[0], startXY[1], endXY[0], endXY[1]);
        rect.sort();
    }

    private static int[] getCoordinates(TextView textView, int index, boolean startCoordinate) {
        final Layout layout = textView.getLayout();
        final int line = layout.getLineForOffset(index);
        final int x = (int) layout.getPrimaryHorizontal(index);
        final int y = startCoordinate ? layout.getLineTop(line) : layout.getLineBottom(line);
        final int[] xy = new int[2];
        textView.getLocationOnScreen(xy);
        return new int[]{
                x + textView.getTotalPaddingLeft() - textView.getScrollX() + xy[0],
                y + textView.getTotalPaddingTop() - textView.getScrollY() + xy[1]};
    }

    private static SupportMenu createMenu(
            final TextView textView,
            final BackgroundSpan highlight,
            List<RemoteActionCompat> actions) {
        final MenuBuilder menu = new MenuBuilder(textView.getContext());
        final int size = actions.size();
        final Map<MenuItem, PendingIntent> menuActions = new ArrayMap<>(size);
        for (int i = 0; i < size; i++) {
            final RemoteActionCompat action = actions.get(i);
            final MenuItem item = menu.add(
                    FloatingToolbar.MENU_ID_SMART_ACTION  /* groupId */,
                    i == 0 ? FloatingToolbar.MENU_ID_SMART_ACTION : i  /* itemId */,
                    i == 0 ? 0 : ORDER_START + i  /* order */,
                    action.getTitle()  /* title */);
            if (action.shouldShowIcon()) {
                item.setIcon(action.getIcon().loadDrawable(textView.getContext()));
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                item.setContentDescription(action.getContentDescription());
            }
            item.setShowAsAction(i == 0
                    ? MenuItem.SHOW_AS_ACTION_ALWAYS
                    : MenuItem.SHOW_AS_ACTION_NEVER);
            menuActions.put(item, action.getActionIntent());
        }

        menu.add(Menu.NONE, android.R.id.copy, 1,
                android.R.string.copy)
                .setAlphabeticShortcut('c')
                .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
        menu.add(Menu.NONE, android.R.id.shareText, 2,
                R.string.abc_share)
                .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);

        menu.setCallback(new MenuBuilder.Callback() {
            @Override
            public boolean onMenuItemSelected(MenuBuilder menu, MenuItem item) {
                final PendingIntent intent = menuActions.get(item);
                if (intent != null) {
                    try {
                        intent.send();
                    } catch (PendingIntent.CanceledException e) {
                        Log.e(LOG_TAG, "Error performing smart action", e);
                    }
                } else {
                    switch (item.getItemId()) {
                        case android.R.id.copy:
                            copyText();
                            break;
                        case android.R.id.shareText:
                            shareText();
                            break;
                    }
                }
                return true;
            }

            @Override
            public void onMenuModeChange(MenuBuilder menu) {
            }

            private void copyText() {
                final ClipboardManager clipboard =
                        textView.getContext().getSystemService(ClipboardManager.class);
                final String text = getHighlightedText(textView, highlight);
                if (clipboard != null && !TextUtils.isEmpty(text)) {
                    try {
                        clipboard.setPrimaryClip(ClipData.newPlainText(null, text));
                    } catch (Throwable t) {
                        Log.d(LOG_TAG, "Error copying text: " + t.getMessage());
                    }
                }
            }

            private void shareText() {
                final String text = getHighlightedText(textView, highlight);
                if (!TextUtils.isEmpty(text)) {
                    final Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND);
                    sharingIntent.setType("text/plain");
                    sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, text);
                    textView.getContext().startActivity(Intent.createChooser(sharingIntent, null));
                }
            }
        });
        return menu;
    }

    /* To enable verbose logging. Run the following command:
     * adb shell setprop log.tag.ToolbarController VERBOSE && adb shell stop && adb shell start
     */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    static void logv(String message) {
        if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) {
            Log.v(LOG_TAG, message);
        }
    }

    private static void setListeners(
            TextView textView, int start, int end, IFloatingToolbar toolbar) {
        toolbar.setOnDismissListener(
                new OnToolbarDismissListener(
                        textView,
                        new TextViewListener(toolbar, textView, start, end),
                        new ActionModeCallback(
                                toolbar,
                                textView.getCustomSelectionActionModeCallback(),
                                /* preferMe= */ false),
                        new ActionModeCallback(
                                toolbar,
                                textView.getCustomInsertionActionModeCallback(),
                                /* preferMe= */ true)));
    }

    /**
     * Listens for several TextView events to reposition or dismiss the toolbar.
     */
    private static final class TextViewListener implements
            ViewTreeObserver.OnPreDrawListener,
            ViewTreeObserver.OnWindowFocusChangeListener,
            ViewTreeObserver.OnGlobalFocusChangeListener,
            ViewTreeObserver.OnWindowAttachListener {

        private static final long THROTTLE_DELAY_MS = 300;

        private final IFloatingToolbar mToolbar;
        private final TextView mTextView;
        private final Rect mContentRect;
        private final Rect mTempRect;
        private final int mStart;
        private final int mEnd;

        private long mLastUpdateTimeMs = System.currentTimeMillis() - THROTTLE_DELAY_MS;

        TextViewListener(IFloatingToolbar toolbar, TextView textView, int start, int end) {
            mToolbar = Preconditions.checkNotNull(toolbar);
            mTextView = Preconditions.checkNotNull(textView);
            mContentRect = new Rect();
            mTempRect = new Rect();
            mStart = start;
            mEnd = end;
        }

        @Override
        public boolean onPreDraw() {
            final long now = System.currentTimeMillis();
            if (!maybeDismissToolbar(true, "onPreDraw")
                    && mToolbar.isShowing()
                    && now - mLastUpdateTimeMs >= THROTTLE_DELAY_MS) {
                updateRectCoordinates(mTempRect, mTextView, mStart, mEnd);
                if (!mTempRect.equals(mContentRect)) {
                    // View moved.
                    mContentRect.set(mTempRect);
                    mToolbar.setContentRect(mContentRect);
                    mToolbar.updateLayout();
                    mLastUpdateTimeMs = now;
                }
            }
            return true;
        }

        @Override
        public void onWindowFocusChanged(boolean hasFocus) {
            maybeDismissToolbar(hasFocus, "onWindowFocusChanged");
        }

        @Override
        public void onGlobalFocusChanged(View oldFocus, View newFocus) {
            maybeDismissToolbar(true, "onGlobalFocusChanged");
        }

        @Override
        public void onWindowAttached() {
            maybeDismissToolbar(true, "onWindowAttached");
        }

        @Override
        public void onWindowDetached() {
            maybeDismissToolbar(true, "onWindowDetached");
        }

        private boolean maybeDismissToolbar(boolean assumeWindowFocus, String caller) {
            if (canShowToolbar(mTextView, assumeWindowFocus)) {
                return false;
            }
            logv("TextViewListener." + caller + ": Dismissing toolbar.");
            dismissImmediately(mToolbar);
            return true;
        }
    }

    /**
     * Wraps a textView's action mode callback so the toolbar can react to action mode updates.
     */
    private static final class ActionModeCallback extends ActionMode.Callback2 {

        private final IFloatingToolbar mToolbar;
        @Nullable
        final ActionMode.Callback mOriginalCallback;
        private final boolean mPreferMe;

        ActionModeCallback(
                IFloatingToolbar toolbar,
                @Nullable ActionMode.Callback originalCallback,
                boolean preferMe) {
            mToolbar = Preconditions.checkNotNull(toolbar);
            mOriginalCallback = originalCallback;
            mPreferMe = preferMe;
        }

        @Override
        public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
            if (actionMode.getType() == ActionMode.TYPE_FLOATING) {
                if (mPreferMe) {
                    // Don't start the original action mode if this action mode should be preferred.
                    return false;
                }

                // Dismiss the toolbar if the textView starts a floating action mode.
                // NOTE that TextView by default starts a selection/insertion action mode if no
                // custom callback is set.
                if (mOriginalCallback == null
                        || mOriginalCallback.onCreateActionMode(actionMode, menu)) {
                    logv("ActionModeCallback: Dismissing toolbar. hasCallback="
                            + (mOriginalCallback != null));
                    dismissImmediately(mToolbar);
                    return true;
                }
                return false;
            }
            return mOriginalCallback.onCreateActionMode(actionMode, menu);
        }

        @Override
        public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
            // If another toolbar is showing, this toolbar should not be showing.
            mToolbar.dismiss();

            return mOriginalCallback == null
                    || mOriginalCallback.onPrepareActionMode(actionMode, menu);
        }

        @Override
        public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
            return mOriginalCallback != null
                    && mOriginalCallback.onActionItemClicked(actionMode, menuItem);
        }

        @Override
        public void onDestroyActionMode(ActionMode actionMode) {
            if (mOriginalCallback != null) {
                mOriginalCallback.onDestroyActionMode(actionMode);
            }
        }

        @Override
        public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
            if (mOriginalCallback instanceof ActionMode.Callback2) {
                ((ActionMode.Callback2) mOriginalCallback).onGetContentRect(mode, view, outRect);
            }
        }
    }

    private static final class OnToolbarDismissListener implements PopupWindow.OnDismissListener {

        private final TextView mTextView;
        private final ViewTreeObserver mObserver;
        private final TextViewListener mTextViewListener;
        private final ActionModeCallback mSelectionCallback;
        private final ActionModeCallback mInsertionCallback;

        OnToolbarDismissListener(
                TextView textView,
                TextViewListener textViewListener,
                ActionModeCallback selectionCallback,
                ActionModeCallback insertionCallback) {
            mTextView = Preconditions.checkNotNull(textView);
            mObserver = mTextView.getViewTreeObserver();
            mTextViewListener = Preconditions.checkNotNull(textViewListener);
            registerListeners();
            mSelectionCallback = Preconditions.checkNotNull(selectionCallback);
            mInsertionCallback = Preconditions.checkNotNull(insertionCallback);
            setCallbacks();
        }

        private void registerListeners() {
            mObserver.addOnPreDrawListener(mTextViewListener);
            mObserver.addOnWindowFocusChangeListener(mTextViewListener);
            mObserver.addOnGlobalFocusChangeListener(mTextViewListener);
            mObserver.addOnWindowAttachListener(mTextViewListener);
        }

        private void unregisterListeners() {
            mObserver.removeOnPreDrawListener(mTextViewListener);
            mObserver.removeOnWindowFocusChangeListener(mTextViewListener);
            mObserver.removeOnGlobalFocusChangeListener(mTextViewListener);
            mObserver.removeOnWindowAttachListener(mTextViewListener);
        }

        private void setCallbacks() {
            mTextView.setCustomSelectionActionModeCallback(mSelectionCallback);
            mTextView.setCustomInsertionActionModeCallback(mInsertionCallback);
        }

        private void clearCallbacks() {
            if (mSelectionCallback == mTextView.getCustomSelectionActionModeCallback()) {
                mTextView.setCustomSelectionActionModeCallback(
                        mSelectionCallback.mOriginalCallback);
            }
            if (mInsertionCallback == mTextView.getCustomInsertionActionModeCallback()) {
                mTextView.setCustomInsertionActionModeCallback(
                        mInsertionCallback.mOriginalCallback);
            }
        }

        @Override
        public void onDismiss() {
            removeHighlight(mTextView);
            unregisterListeners();
            clearCallbacks();
        }
    }

    private static final class OnMenuItemClickListener implements MenuItem.OnMenuItemClickListener {

        private final IFloatingToolbar mToolbar;

        OnMenuItemClickListener(IFloatingToolbar toolbar) {
            mToolbar = Preconditions.checkNotNull(toolbar);
        }

        @Override
        public boolean onMenuItemClick(MenuItem item) {
            final Menu menu = mToolbar.getMenu();
            if (menu != null) {
                return menu.performIdentifierAction(item.getItemId(), 0);
            }
            return false;
        }
    }

    /**
     * BackgroundColorSpan that is used to indicate the part of the text that is the subject of the
     * showing toolbar.
     */
    @VisibleForTesting
    static final class BackgroundSpan extends BackgroundColorSpan {

        private static final CharacterStyle NON_PARCELABLE_UNDERLYING = new CharacterStyle() {
            @Override
            public void updateDrawState(TextPaint textPaint) {
            }
        };

        BackgroundSpan(int color) {
            super(color);
        }

        @Override
        public CharacterStyle getUnderlying() {
            // Prevent this span from being parceled.
            return NON_PARCELABLE_UNDERLYING;
        }
    }

    public interface FloatingToolbarFactory {
        @NonNull
        IFloatingToolbar create(@NonNull TextView textView);
    }
}