BottomNavigationItemView長押し時の挙動を制御する

BottomNavigationViewとは

f:id:shaunkawano:20200216145526p:plain

アプリ内の主要な遷移先への移動を可能にするコンポーネントです。

上記スクリーンショットのような、画面下部にボタンを配置するという特徴から"Bottom"Navigationと呼ばれているのではないかと考えています。

BottomNavigationの詳細については、公式サイトをご覧ください。

material.io

BottomNavigationItemView長押し時の挙動を制御したい

特別な処理を行わず、シンプルにBottomNavigationを用いてMenuをinflateさせ表示すると、アイテムを長押しした際に、Tooltipが表示されます。

f:id:shaunkawano:20200307153644p:plain

(上記スクリーンショットGoogle Payアプリの画面です。)

レアケースかもしれませんが、(というか、アクセシビリティ観点などから見ると非推奨なのかもしれません。) たとえばどうしてもこのTooltipを非表示にしたかったり、ユーザーがアイテムを長押しした際に独自の処理を行いたい場合には、このようにできますよ、という雰囲気のメモです。

ちなみに今確認したところ、Twitterアプリは長押し時にTooltipが表示されていないようなので、もしBottomNavigationViewを利用している場合は制御を行っている可能性があります。

先にコードだけ書いておくと、以下のようにすることで制御が可能です

bottomNavigation.menu.forEach {
  val view = bottomNavigation.findViewById<View>(it.itemId)
  view.setOnLongClickListener {
    // よしなに行いたい処理を記述
    true
  }
}

この記事の後半では、BottomNavigationViewの内部のコードなどを見ていきます。

Tooltipはどのように表示されているのか

冒頭にも書きましたが、BottomNavigationViewを用いると、長押しした際の標準の挙動としてこのTooltipが表示されます。 このTooltipが、どのように表示されているのか、ざっくり処理を追っていきます。

処理を追うBottomNavigationViewが入っているMaterial Componentsのバージョンです: com.google.android.material:material:1.2.0-alpha05

BottomNavigationView#inflateMenu

まず、BottomNavigationViewをXML上で定義する際に、menuを指定します。(もしくはコード上からinflateMenu関数を呼び出すことも可能です。)

 <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottomNavigation"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        ...
        app:menu="@menu/bottom_navigation_menu" // menuResを指定
        />

こうすると、BottomNavigationViewのコンストラクタ内で、inflateMenu関数が呼ばれます。

public void inflateMenu(int resId) {
    presenter.setUpdateSuspended(true);
    getMenuInflater().inflate(resId, menu);
    presenter.setUpdateSuspended(false);
    presenter.updateMenuView(true);
}

BottomNavigationPresenter#updateMenuView

BottomNavigationView.inflateMenu関数内部では、MenuInflaterクラスのinflate関数を呼び出しMenuをinflateしています。 また、BottomNavigationPresenterというPresenterクラスの関数をいくつか呼び出しています。

注目したいのは、BottomNavigationPresenter.updateMenuViewです。この関数を見ていきます。

@Override
  public void updateMenuView(boolean cleared) {
    if (updateSuspended) {
      return;
    }
    if (cleared) {
      menuView.buildMenuView();
    } else {
      menuView.updateMenuView();
    }
  }

BottomNavigationView.inflateMenu関数内部では、

presenter.updateMenuView(true);

という呼び出しをしているため、引数のclearedの値はtrueが入っています。そのため、(もしupdateSuspendedというフィールドの値がtrueでなければ、)以下のブロックが実行されます。

menuView.buildMenuView();

BottomNavigationItemView#initialize

BottomNavigationMenuView.buildMenuView関数の内部では、BottomNavigationViewで表示する各アイテムのView(=BottomNavigationItemView)を初期化します。 一部抜粋すると、以下のようになっています:

BottomNavigationItemView child = getNewItem();
      ...
      child.initialize((MenuItemImpl) menu.getItem(i), 0);
      child.setItemPosition(i);
      child.setOnClickListener(onClickListener);
      if (selectedItemId != Menu.NONE && menu.getItem(i).getItemId() == selectedItemId) {
        selectedItemPosition = i;
      }
      setBadgeIfNeeded(child);
      addView(child);

ここでは、setOnClickListenerなどを呼んでいる箇所はありますが、長押し時のためのリスナーをセットするsetOnLongLickListenerなどは呼び出ししていません。 ということで、BottomNavigationItemView.initializeのコード内部を読みます。まるっと抜粋すると・・

@Override
  public void initialize(@NonNull MenuItemImpl itemData, int menuType) {
    this.itemData = itemData;
    setCheckable(itemData.isCheckable());
    setChecked(itemData.isChecked());
    setEnabled(itemData.isEnabled());
    setIcon(itemData.getIcon());
    setTitle(itemData.getTitle());
    setId(itemData.getItemId());
    if (!TextUtils.isEmpty(itemData.getContentDescription())) {
      setContentDescription(itemData.getContentDescription());
    }

    CharSequence tooltipText = !TextUtils.isEmpty(itemData.getTooltipText())
        ? itemData.getTooltipText()
        : itemData.getTitle();
    TooltipCompat.setTooltipText(this, tooltipText);
    setVisibility(itemData.isVisible() ? View.VISIBLE : View.GONE);
  }

ここでtooltipTextという文字が見えます。何やらここでいろいろやってそうです。

TooltipCompat.setTooltipText

CharSequence tooltipText = !TextUtils.isEmpty(itemData.getTooltipText())
  ? itemData.getTooltipText()
  : itemData.getTitle();
TooltipCompat.setTooltipText(this, tooltipText);

TooltipCompat.setTooltipTextというのは、それっぽい名前ですね。内部を見ると、

public static void setTooltipText(@NonNull View view, @Nullable CharSequence tooltipText) {
        if (Build.VERSION.SDK_INT >= 26) {
            view.setTooltipText(tooltipText);
        } else {
            TooltipCompatHandler.setTooltipText(view, tooltipText);
        }
    }

SDKバージョンが26以上の場合はView.setTooltipTextを呼び出しています。

TooltipCompatHandler.setTooltipText

if (Build.VERSION.SDK_INT >= 26) {
  view.setTooltipText(tooltipText);
} else {
  TooltipCompatHandler.setTooltipText(view, tooltipText);
}

elseの分岐時に実行される、TooltipCompatHandler.setTooltipTextの処理を見ていきます。

if (TextUtils.isEmpty(tooltipText)) {
            if (sActiveHandler != null && sActiveHandler.mAnchor == view) {
                sActiveHandler.hide();
            }
            view.setOnLongClickListener(null);
            view.setLongClickable(false);
            view.setOnHoverListener(null);
        } else {
            new TooltipCompatHandler(view, tooltipText);
        }

なにやらtooltipTextが空の場合はviewに対してsetOnLongClickListener(null)しています。tooltipTextが空ではない場合は、TooltipCompatHandlerクラスを初期化しています。 このクラスのコンストラクタでなにかやっていそうです。

TooltipCompatHandler

private TooltipCompatHandler(View anchor, CharSequence tooltipText) {
        mAnchor = anchor;
        mTooltipText = tooltipText;
        mHoverSlop = ViewConfigurationCompat.getScaledHoverSlop(
                ViewConfiguration.get(mAnchor.getContext()));
        clearAnchorPos();

        mAnchor.setOnLongClickListener(this);
        mAnchor.setOnHoverListener(this);
    }

実行されているのは上記のコンストラクタです。 引数に渡ってきたViewのsetOnLongClickListenerを呼び出し、自身をListenerとしてセットしています。お察しかもしれませんが、TooltipCompatHandlerはView.OnLongClickListenerを実装しています。

@Override
    public boolean onLongClick(View v) {
        mAnchorX = v.getWidth() / 2;
        mAnchorY = v.getHeight() / 2;
        show(true /* from touch */);
        return true;
    }

SDK26未満のバージョンの場合には、こちらのコードが実行されていそう、ということがわかりました。

つまり、もともとやりたかったことに話を戻すと、ここに渡ってくるViewに対してOnLongClickListenerを上書きでセットすればよさそうです。 ここに渡ってくるViewというのは、BottomNavigationMenuView.buildMenuView関数の内部で初期化していたBottomNavigationItemViewです。 これらのViewは、BottomNavigation.getMenu関数で取得したMenuのgetItem関数にてアクセスできます。一つのMenuアイテムを取得する場合にはこれでもよいですが、たとえばすべてのMenuにたいしてアクセスしたい場合などには、androidx.corecore-ktxライブラリに便利なKotlin拡張関数が用意されているため、こちらを活用できます。

/** Performs the given action on each item in this menu. */
inline fun Menu.forEach(action: (item: MenuItem) -> Unit) {
    for (index in 0 until size()) {
        action(getItem(index))
    }
}

上記の拡張関数を利用すると、最終的には以下のようなコードで、BottomNavigationItemView長押し時の処理を制御できます。

bottomNavigation.menu.forEach {
  val view = bottomNavigation.findViewById<View>(it.itemId)
  view.setOnLongClickListener {
    // よしなに行いたい処理を記述
    true
  }
}

以上です。 もしなにか間違いやご指摘等ありましたら、コメントやTwitterのDM等いただけますと幸いです🙏

参考リンクなど

  • 参考にしたStackoverflow

stackoverflow.com

stackoverflow.com