+4

Sử dụng custom view làm MenuItem

Android framework giúp chúng ta rất nhiều trong việc tạo và tương tác với các menu item - những icon nhỏ ở phía bên phải của toolbar. Chỉ bằng việc gọi 1 vài hàm thiết lập, framework sẽ tự động giải quyết 3 vấn đề sau:

  1. Chèn 1 view vào Toolbar và đảm bảo là nó ở đúng vị trí, đúng hình ảnh và đúng padding với các view khác.
  2. Thêm 1 click listener cho view đó.
  3. Định nghĩa phản hồi trực quan khi được click (ví dụ như màu của background thay đổi hoặc hiệu ứng ripple).

Việc duy nhất chúng ta cần làm là định nghĩa tiêu đề và icon drawable trong file menu layout, inflate layout này trong onCreateOptionsMenu() và định nghĩa các action cho click listener trong onOptionsItemSelected(). Nếu bạn đã từng làm việc với menu từ trước rồi thì những thứ này không có gì mới.

R.menu.activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/activity_main_update_menu_item"
        android:icon="@drawable/ic_refresh_white_24dp"
        android:title="Update"
        app:showAsAction="ifRoom"/>

</menu>

MainActivity.java

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        super.onCreateOptionsMenu(menu);
        getMenuInflater().inflate(R.menu.activity_main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.activity_main_update_menu_item:
                Toast.makeText(this, "update clicked", Toast.LENGTH_SHORT).show();
                return true;

            default:
                return super.onOptionsItemSelected(item);
        }

    }
}

first image.gif

Nhưng chúng ta phải làm gì nếu muốn sử dụng custom view thay vì chỉ định nghĩa 1 icon drawable? Giả sử chúng ta có 1 view để hiển thị số thông báo mà ứng dụng của chúng ta nhận được. Chúng ta cần phải update icon để hiện hoặc ẩn 1 vòng tròn màu đỏ có 1 số nằm trong. Và giả sử là menu item "refresh" sẽ request số thông báo mới nhất và update menu item "alert". Kết quả cuối cùng mà chúng ta mong muốn sẽ như thế này:

second image.gif

Chúng ta có thể dễ dàng thay đổi icon được sử dụng trong 1 menu item. Chúng ta thậm chí có thể "gian lận" bằng cách cung cấp 11 icon khác nhau sau đó lần lượt thay chúng vào khi cần:

icon không có vòng tròn đỏ

icon với vòng tròn đỏ rỗng

icon với vòng tròn đỏ và số "1"

icon với vòng tròn đỏ và số "2"

...

icon với vòng tròn đỏ và số "9"

Trong khi việc này có thể dễ dàng hơn cho các lập trình viên chúng ta (nhưng lại nhiều việc hơn cho các designer), những icon đó không sớm thì muộn sẽ làm cho file apk bị tăng dung lượng. Thay vào đó, chúng ta có thể đối xử tốt hơn với người dùng bằng cách dựa vào custom view để đạt được kết quả mong muốn với ít resource cần thêm vào hơn.

Định nghĩa 1 Custom View

Chìa khóa để sử dụng 1 custom view để làm icon cho menu item là sử dụng app:actionLayout thay vì android:icon trong file menu.

R.menu.activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/activity_main_alerts_menu_item"
        android:title="Alerts"
        app:actionLayout="@layout/view_alertsbadge" <!-- phần quan trọng -->
        app:showAsAction="ifRoom"/>

    <item
        android:id="@+id/activity_main_update_menu_item"
        android:icon="@drawable/ic_refresh_white_24dp"
        android:title="Update"
        app:showAsAction="ifRoom"/>

</menu>

Next we will layout our custom view in a normal layout file.

Tiếp theo chúng ta sẽ định nghĩa layout cho custom view

R.layout.view_alertsbadge.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="32dp"
    android:layout_height="32dp"
    android:layout_gravity="center">

    <ImageView
        android:layout_width="@dimen/menu_item_icon_size"
        android:layout_height="@dimen/menu_item_icon_size"
        android:layout_gravity="center"
        android:src="@drawable/ic_warning_white_24dp"/>

    <FrameLayout
        android:id="@+id/view_alert_red_circle"
        android:layout_width="14dp"
        android:layout_height="14dp"
        android:layout_gravity="top|end"
        android:background="@drawable/circle_red"
        android:visibility="gone"
        tools:visibility="visible">

        <TextView
            android:id="@+id/view_alert_count_textview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:textColor="@color/white"
            android:textSize="10sp"
            tools:text="3"/>

    </FrameLayout>

</FrameLayout>

Cuối cùng chúng ta định nghĩa kích thước cho icon bằng việc tham khảo Material Design guidelines:

dimens.xml

<resources>
    <!-- general dimensions for all custom menu items -->
    <dimen name="menu_item_icon_size">24dp</dimen>
</resources>

Giờ chúng ta đã có 1 vòng tròn đỏ FrameLayout có chứa 1 TextView để hiển thị số thông báo. Chúng ta cũng có 1 ImageView để làm icon cảnh báo. Sau cùng thì chúng ta gói tất cả lại vào trong 1 FrameLayout gốc. Cần chú ý là chúng ta cần phải hardcode kích thước của icon theo tiêu chuẩn của Material design.

Khi đã có layout cho menu rồi thì hãy cho nó vào activity


@Override
public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
        case R.id.activity_main_update_menu_item:
            // TODO update alert menu icon
            Toast.makeText(this, "update clicked", Toast.LENGTH_SHORT).show();
            return true;

        ...
    }
}

Nếu chúng ta chạy ứng dụng ngay thì chúng ta sẽ thấy icon mới, nhưng lại có 2 vấn đề phát sinh:

  1. onOptionsItemSelected sẽ không được gọi khi ấn vào menu item chúng ta vừa tạo.
  2. Icon không có phản hồi trực quan khi được click.

3rd image.gif

Chúng ta sẽ fix những vấn đề này sớm thôi, nhưng trước hết thì hãy viết đoạn code để làm cho icon hiển thị số thông báo khi được request.

Sử dụng Custom View

Chúng ta muốn config custom view trong menu item mỗi khi view được vẽ lên. Nên thay vì config nó trong onCreateOptionsMenu, chúng ta sẽ làm vài việc trong onPrepareOptionsMenu. Vì menu item của chúng ta chỉ là 1 layout đã được inflate, chúng ta có thể làm việc với nó như với bất kì layout nào khác, ví dụ chúng ta có thể tìm view bằng id.


public class MainActivity extends AppCompatActivity {

    private FrameLayout redCircle;
    private TextView countTextView;
    private int alertCount = 0;

    ...

    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        final MenuItem alertMenuItem = menu.findItem(R.id.activity_main_alerts_menu_item);
        FrameLayout rootView = (FrameLayout) alertMenuItem.getActionView();

        redCircle = (FrameLayout) rootView.findViewById(R.id.view_alert_red_circle);
        countTextView = (TextView) rootView.findViewById(R.id.view_alert_count_textview);

        return super.onPrepareOptionsMenu(menu);
    }

    ...
}

Chúng ta có thể truy cập đến root view của menu item bằng cách tìm item đó trong menu và gọi hàm getActionView. Chúng ta sau đó có thể tìm thấy vòng tròn đỏ FrameLayout và số thông báo TextView.

Chúng ta sẽ update icon thông báo mỗi khi user click vào nút "refresh" trên thanh menu:


public class MainActivity extends AppCompatActivity {

    private FrameLayout redCircle;
    private TextView countTextView;
    private int alertCount = 0;

    ...

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.activity_main_update_menu_item:
                alertCount = (alertCount + 1) % 11; // cycle through 0 - 10
                updateAlertIcon()
                return true;

            case R.id.activity_main_alerts_menu_item:
                // TODO update alert menu icon
                Toast.makeText(this, "count cleared", Toast.LENGTH_SHORT).show();

            default:
                return super.onOptionsItemSelected(item);
        }
    }

    private void updateAlertIcon() {
        // if alert count extends into two digits, just show the red circle
        if (0 < alertCount && alertCount < 10) {
            countTextView.setText(String.valueOf(alertCount));
        } else {
            countTextView.setText("");
        }

        redCircle.setVisibility((alertCount > 0) ? VISIBLE : GONE);
    }
}

Giờ thì chúng ta đã update được menu item này:

new 4th.gif

Fix các vấn đề AKA làm nó trở nên hoàn hảo

Như đã nói ở trên thì chúng ta vẫn còn 2 vấn đề:

  1. onOptionsItemSelected sẽ không được gọi khi ấn vào menu item chúng ta vừa tạo.
  2. Icon không có phản hồi trực quan khi được click.

Hãy cùng xử lý cái đầu tiên trước. Vì 1 vài lí do, khi menu item của chúng ta phụ thuộc vào app:actionLayout thay vì android:icon, onOptionsItemSelected sẽ không được gọi cho menu item đó. Đây là 1 vấn đề đã được biết đến. Giải pháp cho vấn đề này đơn giản là chúng ta tự thêm click listener cho root view và gọi tự gọi onOptionsItemSelected. Hãy cùng reset số thông báo khi người dùng click vào item "alert":


    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        final MenuItem alertMenuItem = menu.findItem(R.id.activity_main_alerts_menu_item);
        FrameLayout rootView = (FrameLayout) alertMenuItem.getActionView();

        redCircle = (FrameLayout) rootView.findViewById(R.id.view_alert_red_circle);
        countTextView = (TextView) rootView.findViewById(R.id.view_alert_count_textview);

        rootView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                onOptionsItemSelected(alertMenuItem);
            }
        });

        return super.onPrepareOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.activity_main_update_menu_item:
                alertCount = (alertCount + 1) % 11; // rotate through 0 - 10
                updateAlertIcon();
                return true;

            case R.id.activity_main_alerts_menu_item:
                alertCount = 0;
                updateAlertIcon();
                return true;

            default:
                return super.onOptionsItemSelected(item);
        }
    }

Bây giờ nếu người dùng sử dụng app, họ sẽ thấy 1 số thứ không đúng. Nó sẽ mất 1 chút thời gian để tìm ra cái không đúng ở đây là gì. Họ sẽ thấy phải click vào icon alert nhiều lần trước khi nó hoạt động. Như vậy với những lập trình viên tỉ mỉ như chúng ta thì chúng ta sẽ bật "show layout bounds" trong developer options và sẽ phát hiện ra vấn đề:

5th image.png

Custom menu item của chúng ta không được tự động cung cấp padding giống như những menu item thường khác. Bởi vậy thì vùng nhận được các touch event sẽ bị giảm đáng kể. Người dùng của chúng ta sẽ phải thử rất nhiều lần để tìm ra vùng đúng để click. Chúng ta có thể fix lỗi này bằng cách thêm 1 FrameLayout cho custom view:

R.layout.view_alertsbadge.xml


<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="@dimen/menu_item_size"
    android:layout_height="@dimen/menu_item_size">

    <FrameLayout
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_gravity="center">

        <ImageView
            android:layout_width="@dimen/menu_item_icon_size"
            android:layout_height="@dimen/menu_item_icon_size"
            android:layout_gravity="center"
            android:src="@drawable/ic_warning_white_24dp"/>

        <FrameLayout
            android:id="@+id/view_alert_red_circle"
            android:layout_width="14dp"
            android:layout_height="14dp"
            android:layout_gravity="top|end"
            android:background="@drawable/circle_red"
            android:visibility="gone"
            tools:visibility="visible">

            <TextView
                android:id="@+id/view_alert_count_textview"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:textColor="@color/white"
                android:textSize="10sp"
                tools:text="3"/>

        </FrameLayout>

    </FrameLayout>

</FrameLayout>

Chúng ta cần phải set root view mới này có kích thước 48dp cả chiều rộng và chiều cao theo tiêu chuẩn của Material design.

dimens.xml


<resources>
    <!-- general dimensions for all custom menu items -->
    <dimen name="menu_item_icon_size">24dp</dimen>
    <dimen name="menu_item_size">48dp</dimen>
</resources>

Điều này sẽ làm tăng kích thước vùng click của chúng ta.

sixth image.png

Điều cuối cùng chúng ta phải làm là kích hoạt vài phản hồi hình ảnh khi menu item được click. Đối với các máy từ Lollipop trở lên thì hiệu ứng ở đây là ripple, còn với những máy đời cũ hơn thì màu background sẽ thay đổi. May mắn cho chúng ta là chức năng này đã có sẵn trong attr/selectableItemBackgroundBorderless. Nên tất cả những gì chúng ta cần là 1 view trong file layout để có thể set thuộc tính này vào nó.

R.layout.view_alertsbadge.xml


<?xml version="1.0" encoding="utf-8"?>

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="@dimen/menu_item_size"
    android:layout_height="@dimen/menu_item_size">

    <!-- View riêng lẻ để hiển thị hiệu ứng ripple/màu thay đổi khi menu item được click -->
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:background="?attr/selectableItemBackgroundBorderless"/>

    ...

</FrameLayout>

Hoàn hảo. Mọi thứ nhìn khá là ổn. Hiệu ứng ripple nhìn rất chân thật trên Android 22 và màu background thay đổi cũng hoạt động tốt trên Android 19.

7th image.gif

Android 19

eighth image.gif

Android 22

Điều cuối cùng

Trước khi chúng tôi đưa đoạn code này vào production, chúng tôi đã chạy phần thay đổi này trên device farm và nhận ra 1 số thứ không đúng trên các device chạy Android 23 trở lên. Kích thước của hiệu ứng ripple trên menu item của chúng ta trở nên lớn hơn rất nhiều so với menu item thường:

10th image.png

Hiệu ứng ripple của menu item thường

11th image.png

Hiệu ứng ripple của menu item của chúng ta

Để fix lỗi này chúng tôi đã phải thử nghiệm khá nhiều để tìm ra kích thước chuẩn của ripple. Sau đó ta cần cung cấp 1 dimen riêng cho những device chạy API 23 trở lên. Sau cùng thì chúng ta sẽ update file layout để sử dụng kích thước mới thay vì để cho view chứa ripple là match_parent. Bạn có thể không tin tôi nhưng kích thước của ripple trên API 23+ nên là 28dp.

values/dimens.xml


<resources>
    <!-- general dimensions for all custom menu items -->
    <dimen name="menu_item_icon_size">24dp</dimen>
    <dimen name="menu_item_size">48dp</dimen>
    <dimen name="menu_item_ripple_size">48dp</dimen>
</resources>

values-v23/dimens.xml


<resources>
    <dimen name="menu_item_ripple_size">28dp</dimen>
</resources>

R.layout.view_alertsbadge.xml


<?xml version="1.0" encoding="utf-8"?>

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="@dimen/menu_item_size"
    android:layout_height="@dimen/menu_item_size">

     <!-- separate view to display ripple/color change when menu item is clicked -->
    <FrameLayout
        android:layout_width="@dimen/menu_item_ripple_size"
        android:layout_height="@dimen/menu_item_ripple_size"
        android:layout_gravity="center"
        android:background="?attr/selectableItemBackgroundBorderless"/>

    ...

</FrameLayout>

Chúng ta giờ đã có hiệu ứng ripple với kích thước giống như menu item thường.

12th image.png

Đó là tất cả. Cách giải quyết của chúng ta giờ đã hoạt động trên tất cả các version của Android. Bạn có thể download source code tại đây.

13th image.gif

API 19

14th image.gif

API 22

15th image.gif

API 24

Bài viết được dịch từ USING CUSTOM VIEWS AS MENU ITEMS của tác giả Jason Atwood. Cảm ơn các bạn đã theo dõi.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.