Android アルファベットインデックスサイドバーの実装

この記事では、A〜Zおよび「#」の27文字からなる索引バーを実装する方法を解説します。手指のタッチ位置に応じて対応する文字を強調表示し、画面中央に表示する形で、連絡先アプリのようなUXを実現します。

カスタム属性の定義

スタイルやテーマによって変更可能な.penowを備えるために、attrs.xmlで属性群を宣言します。

<!-- res/values/attrs.xml -->
<resources>
    <declare-styleable name="AlphabetIndexBar">
        <attr name="normalSize" format="dimension"/>
        <attr name="highlightSize" format="dimension"/>
        <attr name="normalColor" format="color"/>
        <attr name="highlightColor" format="color"/>
    </declare-styleable>
</resources>

文字列描画とタッチ制御

既存の TextView を拡張し、文字描画とタッチ判定フラグを分離した構造にします。这里では、皆が知っている文字列配列 A-Z と # で初期化し、通常時と選択時それぞれに異なるペンスタイル(Paint)オブジェクトを生成します。

public class AlphabetIndexBar extends AppCompatTextView {
    private Paint defaultPaint;
    private Paint highlightPaint;
    private final String[] letters = new String[27];
    private int selectedPosition = -1;
    private float highlightBaseline = 0;

    public AlphabetIndexBar(Context context, AttributeSet attrs) {
        super(context, attrs);
        
        for (int i = 0; i < 26; i++) {
            letters[i] = String.valueOf((char) ('A' + i));
        }
        letters[26] = "#";

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AlphabetIndexBar);
        int normalSize = a.getDimensionPixelSize(R.styleable.AlphabetIndexBar_normalSize, sp2px(12));
        int highlightSize = a.getDimensionPixelSize(R.styleable.AlphabetIndexBar_highlightSize, sp2px(14));
        int normalColor = a.getColor(R.styleable.AlphabetIndexBar_normalColor, getCurrentTextColor());
        int highlightColor = a.getColor(R.styleable.AlphabetIndexBar_highlightColor, getCurrentTextColor());
        
        defaultPaint = createPaint(normalColor, normalSize);
        highlightPaint = createPaint(highlightColor, highlightSize);
        a.recycle();

        setFocusable(true);
    }

    private Paint createPaint(int color, float size) {
        Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
        p.setColor(color);
        p.setDither(true);
        p.setTextSize(size);
        return p;
    }

    private int sp2px(float sp) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, getResources().getDisplayMetrics());
    }

計測処理の実装

onMeasure() では文字の寸法を正確に測定し、縦方向の高さを文字数で均等割りするための基本サイズを算出します。

@Override
protected void onMeasure(int widthSpec, int heightSpec) {
    super.onMeasure(widthSpec, heightSpec);
    
    Rect bounds = new Rect();
    highlightPaint.getTextBounds("M", 0, 1, bounds);
    
    int desiredWidth = bounds.width() + getPaddingLeft() + getPaddingRight();
    int desiredHeight = (bounds.height() + 2) * letters.length 
                        + getPaddingTop() + getPaddingBottom();

    setMeasuredDimension(
        resolveSize(desiredWidth, widthSpec),
        resolveSize(desiredHeight, heightSpec)
    );
}

描画処理の最適化

文字の垂直位置は FontMetricsInt を用いて中央揃え調整し、高輝度の文字 been 起動された場合にのみ座標を再計算するよう最適化しています。それによりinvalidate()の頻度を減らし、負荷を軽減します。

@Override
protected void onDraw(Canvas canvas) {
    int totalItems = letters.length;
    int cellHeight = (getHeight() - getPaddingTop() - getPaddingBottom()) / totalItems;

    for (int i = 0; i < totalItems; i++) {
        Paint paint = (i == selectedPosition) ? highlightPaint : defaultPaint;
        FontMetricsInt fmi = paint.getFontMetricsInt();

        int y = getPaddingTop() + i * cellHeight + cellHeight / 2 
                - (fmi.bottom - fmi.top) / 2 - fmi.bottom;
        
        String text = letters[i];
        float x = (getWidth() - paint.measureText(text)) / 2;

        canvas.drawText(text, x, y, paint);
        
        if (i == selectedPosition) {
            highlightBaseline = y;
        }
    }
}

タッチイベントの処理

ACTION_DOWN および ACTION_MOVE 時に手指の Y 座標を元に添字を決定し、対応する文字の描画を更新。選択変更がある場合にのみ invalidate() を呼ぶことで不要な再描画を防止します。

@Override
public boolean onTouchEvent(MotionEvent event) {
    float touchY = event.getY();
    int position = (int) (touchY / (getHeight() / letters.length));

    if (event.getAction() == MotionEvent.ACTION_DOWN || event.getAction() == MotionEvent.ACTION_MOVE) {
        position = Math.max(0, Math.min(position, letters.length - 1));

        if (position != selectedPosition) {
            selectedPosition = position;
            invalidate();
            if (callback != null) {
                callback.onIndexTouched(letters[selectedPosition], true, highlightBaseline);
            }
        }
    } else if (event.getAction() == MotionEvent.ACTION_UP) {
        if (callback != null) {
            callback.onIndexTouched(letters[selectedPosition], false, 0);
        }
        selectedPosition = -1;
        invalidate();
    }
    return true;
}

コールバックによる親Viewとの連携

カスタムインタフェースを介して、外部 Activity や Fragment へタッチ情報を通知します。Activity 側ではこの情報を元に FLOATING な文字表示ダイアムを 얻制御します。

public interface IndexSelectionListener {
    void onIndexTouched(String letter, boolean isPressed, float yPosition);
}

MainActivity 使用例

onIndexTouched()により、タッチ中の場合はダイアムを表示し、解除場合は500ms後に非表示にする実装です。幅度調整はマージンではなく translationY を用いて軽量に実行しています。

public classMainActivity extends AppCompatActivity implements IndexSelectionListener {
    private TextView overlay;
    private AlphabetIndexBar sidebar;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        sidebar = findViewById(R.id.alphabet_bar);
        overlay = findViewById(R.id.overlay_text);
        sidebar.setOnIndexSelectionListener(this);
    }

    @Override
    public void onIndexTouched(String letter, boolean pressed, float y) {
        overlay.setText(letter);
        
        if (pressed) {
            overlay.setVisibility(VISIBLE);
            overlay.setTranslationY(y - overlay.getHeight() / 2);
        } else {
            overlay.animate().alpha(0).setDuration(300).withEndAction(() -> 
                overlay.setVisibility(INVISIBLE)
            ).start();
        }
    }
}

レイアウト構成

letter_side_bar は右側に固定配置し、overlay 文字は画面中央付近に配置します。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <Space
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="match_parent"/>

    <TextView
        android:id="@+id/overlay_text"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:background="@drawable/bubble_background"
        android:gravity="center"
        android:textColor="@android:color/white"
        android:textSize="20sp"
        android:visibility="invisible"/>

    <com.example.AlphabetIndexBar
        android:id="@+id/alphabet_bar"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:padding="6dp"
        app:normalColor="@color/gray"
        app:highlightColor="@android:color/black"
        app:normalSize="12sp"
        app:highlightSize="14sp"/>

</LinearLayout>

タグ: Android CustomView TouchHandling LetterIndex InterfaceCallback

5月23日 10:27 投稿