Search code examples
androidtoolbarandroid-appcompatsearchviewmaterial-design

Creating a SearchView that looks like the material design guidelines


I'm currently in the process of learning how to convert my app to Material design and I'm a bit stuck right now. I've got the Toolbar added and I have made my navigation drawer overlay all the content.

I'm now trying to create an expandable search that looks like the one in the material guidelines: enter image description here

This is what I've got right now and I can't figure out how to make it like the above:
My search

This is my menu 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/action_search"
        android:icon="@android:drawable/ic_menu_search"
        android:title="Search"
        app:showAsAction="always"
        app:actionViewClass="android.support.v7.widget.SearchView" />
</menu>

That works, I get a menu item that expands to the SearchView and I can filter my list fine. It doesn't look anything like the 1st picture though.

I tried to use MenuItemCompat.setOnActionExpandListener() on R.id.action_search so I could change the home icon to a back arrow, but that doesn't seem to work. Nothing gets fired in the listener. Even if that worked it still wouldn't be very close to the 1st image.

How do I create a SearchView in the new appcompat toolbar that looks like the material guidelines?


Solution

  • After a week of puzzling over this. I think I've figured it out.
    I'm now using just an EditText inside of the Toolbar. This was suggested to me by oj88 on reddit.

    I now have this:
    New SearchView

    First inside onCreate() of my activity I added the EditText with an image view on the right hand side to the Toolbar like this:

        // Setup search container view
        searchContainer = new LinearLayout(this);
        Toolbar.LayoutParams containerParams = new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        containerParams.gravity = Gravity.CENTER_VERTICAL;
        searchContainer.setLayoutParams(containerParams);
    
        // Setup search view
        toolbarSearchView = new EditText(this);
        // Set width / height / gravity
        int[] textSizeAttr = new int[]{android.R.attr.actionBarSize};
        int indexOfAttrTextSize = 0;
        TypedArray a = obtainStyledAttributes(new TypedValue().data, textSizeAttr);
        int actionBarHeight = a.getDimensionPixelSize(indexOfAttrTextSize, -1);
        a.recycle();
        LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, actionBarHeight);
        params.gravity = Gravity.CENTER_VERTICAL;
        params.weight = 1;
        toolbarSearchView.setLayoutParams(params);
    
        // Setup display
        toolbarSearchView.setBackgroundColor(Color.TRANSPARENT);
        toolbarSearchView.setPadding(2, 0, 0, 0);
        toolbarSearchView.setTextColor(Color.WHITE);
        toolbarSearchView.setGravity(Gravity.CENTER_VERTICAL);
        toolbarSearchView.setSingleLine(true);
        toolbarSearchView.setImeActionLabel("Search", EditorInfo.IME_ACTION_UNSPECIFIED);
        toolbarSearchView.setHint("Search");
        toolbarSearchView.setHintTextColor(Color.parseColor("#b3ffffff"));
        try {
            // Set cursor colour to white
            // https://stackoverflow.com/a/26544231/1692770
            // https://github.com/android/platform_frameworks_base/blob/kitkat-release/core/java/android/widget/TextView.java#L562-564
            Field f = TextView.class.getDeclaredField("mCursorDrawableRes");
            f.setAccessible(true);
            f.set(toolbarSearchView, R.drawable.edittext_whitecursor);
        } catch (Exception ignored) {
        }
    
        // Search text changed listener
        toolbarSearchView.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
            }
    
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                Fragment mainFragment = getFragmentManager().findFragmentById(R.id.container);
                if (mainFragment != null && mainFragment instanceof MainListFragment) {
                    ((MainListFragment) mainFragment).search(s.toString());
                }
            }
    
            @Override
            public void afterTextChanged(Editable s) {
                // https://stackoverflow.com/a/6438918/1692770
                if (s.toString().length() <= 0) {
                    toolbarSearchView.setHintTextColor(Color.parseColor("#b3ffffff"));
                }
            }
        });
        ((LinearLayout) searchContainer).addView(toolbarSearchView);
    
        // Setup the clear button
        searchClearButton = new ImageView(this);
        Resources r = getResources();
        int px = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16, r.getDisplayMetrics());
        LinearLayout.LayoutParams clearParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        clearParams.gravity = Gravity.CENTER;
        searchClearButton.setLayoutParams(clearParams);
        searchClearButton.setImageResource(R.drawable.ic_close_white_24dp); // TODO: Get this image from here: https://github.com/google/material-design-icons
        searchClearButton.setPadding(px, 0, px, 0);
        searchClearButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                toolbarSearchView.setText("");
            }
        });
        ((LinearLayout) searchContainer).addView(searchClearButton);
    
        // Add search view to toolbar and hide it
        searchContainer.setVisibility(View.GONE);
        toolbar.addView(searchContainer);
    

    This worked, but then I came across an issue where onOptionsItemSelected() wasn't being called when I tapped on the home button. So I wasn't able to cancel the search by pressing the home button. I tried a few different ways of registering the click listener on the home button but they didn't work.

    Eventually I found out that the ActionBarDrawerToggle I had was interfering with things, so I removed it. This listener then started working:

        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // toolbarHomeButtonAnimating is a boolean that is initialized as false. It's used to stop the user pressing the home button while it is animating and breaking things.
                if (!toolbarHomeButtonAnimating) {
                    // Here you'll want to check if you have a search query set, if you don't then hide the search box.
                    // My main fragment handles this stuff, so I call its methods.
                    FragmentManager fragmentManager = getFragmentManager();
                    final Fragment fragment = fragmentManager.findFragmentById(R.id.container);
                    if (fragment != null && fragment instanceof MainListFragment) {
                        if (((MainListFragment) fragment).hasSearchQuery() || searchContainer.getVisibility() == View.VISIBLE) {
                            displaySearchView(false);
                            return;
                        }
                    }
                }
    
                if (mDrawerLayout.isDrawerOpen(findViewById(R.id.navigation_drawer)))
                    mDrawerLayout.closeDrawer(findViewById(R.id.navigation_drawer));
                else
                    mDrawerLayout.openDrawer(findViewById(R.id.navigation_drawer));
            }
        });
    

    So I can now cancel the search with the home button, but I can't press the back button to cancel it yet. So I added this to onBackPressed():

        FragmentManager fragmentManager = getFragmentManager();
        final Fragment mainFragment = fragmentManager.findFragmentById(R.id.container);
        if (mainFragment != null && mainFragment instanceof MainListFragment) {
            if (((MainListFragment) mainFragment).hasSearchQuery() || searchContainer.getVisibility() == View.VISIBLE) {
                displaySearchView(false);
                return;
            }
        }
    

    I created this method to toggle visibility of the EditText and menu item:

    public void displaySearchView(boolean visible) {
        if (visible) {
            // Stops user from being able to open drawer while searching
            mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED);
    
            // Hide search button, display EditText
            menu.findItem(R.id.action_search).setVisible(false);
            searchContainer.setVisibility(View.VISIBLE);
    
            // Animate the home icon to the back arrow
            toggleActionBarIcon(ActionDrawableState.ARROW, mDrawerToggle, true);
    
            // Shift focus to the search EditText
            toolbarSearchView.requestFocus();
    
            // Pop up the soft keyboard
            new Handler().postDelayed(new Runnable() {
                public void run() {
                    toolbarSearchView.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, 0, 0, 0));
                    toolbarSearchView.dispatchTouchEvent(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, 0, 0, 0));
                }
            }, 200);
        } else {
            // Allows user to open drawer again
            mDrawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED);
    
            // Hide the EditText and put the search button back on the Toolbar.
            // This sometimes fails when it isn't postDelayed(), don't know why.
            toolbarSearchView.postDelayed(new Runnable() {
                @Override
                public void run() {
                    toolbarSearchView.setText("");
                    searchContainer.setVisibility(View.GONE);
                    menu.findItem(R.id.action_search).setVisible(true);
                }
            }, 200);
    
            // Turn the home button back into a drawer icon
            toggleActionBarIcon(ActionDrawableState.BURGER, mDrawerToggle, true);
    
            // Hide the keyboard because the search box has been hidden
            InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
            imm.hideSoftInputFromWindow(toolbarSearchView.getWindowToken(), 0);
        }
    }
    

    I needed a way to toggle the home button on the toolbar between the drawer icon and the back button. I eventually found the method below in this SO answer. Though I modified it slightly to made more sense to me:

    private enum ActionDrawableState {
        BURGER, ARROW
    }
    
    /**
     * Modified version of this, https://stackoverflow.com/a/26836272/1692770<br>
     * I flipped the start offset around for the animations because it seemed like it was the wrong way around to me.<br>
     * I also added a listener to the animation so I can find out when the home button has finished rotating.
     */
    private void toggleActionBarIcon(final ActionDrawableState state, final ActionBarDrawerToggle toggle, boolean animate) {
        if (animate) {
            float start = state == ActionDrawableState.BURGER ? 1.0f : 0f;
            float end = Math.abs(start - 1);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
                ValueAnimator offsetAnimator = ValueAnimator.ofFloat(start, end);
                offsetAnimator.setDuration(300);
                offsetAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
                offsetAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        float offset = (Float) animation.getAnimatedValue();
                        toggle.onDrawerSlide(null, offset);
                    }
                });
                offsetAnimator.addListener(new Animator.AnimatorListener() {
                    @Override
                    public void onAnimationStart(Animator animation) {
    
                    }
    
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        toolbarHomeButtonAnimating = false;
                    }
    
                    @Override
                    public void onAnimationCancel(Animator animation) {
    
                    }
    
                    @Override
                    public void onAnimationRepeat(Animator animation) {
    
                    }
                });
                toolbarHomeButtonAnimating = true;
                offsetAnimator.start();
            }
        } else {
            if (state == ActionDrawableState.BURGER) {
                toggle.onDrawerClosed(null);
            } else {
                toggle.onDrawerOpened(null);
            }
        }
    }
    

    This works, I've managed to work out a few bugs that I found along the way. I don't think it's 100% but it works well enough for me.

    EDIT: If you want to add the search view in XML instead of Java do this:

    toolbar.xml:

    <android.support.v7.widget.Toolbar 
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/toolbar"
        contentInsetLeft="72dp"
        contentInsetStart="72dp"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"
        android:elevation="4dp"
        android:minHeight="?attr/actionBarSize"
        app:contentInsetLeft="72dp"
        app:contentInsetStart="72dp"
        app:popupTheme="@style/ActionBarPopupThemeOverlay"
        app:theme="@style/ActionBarThemeOverlay">
    
        <LinearLayout
            android:id="@+id/search_container"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center_vertical"
            android:orientation="horizontal">
    
            <EditText
                android:id="@+id/search_view"
                android:layout_width="0dp"
                android:layout_height="?attr/actionBarSize"
                android:layout_weight="1"
                android:background="@android:color/transparent"
                android:gravity="center_vertical"
                android:hint="Search"
                android:imeOptions="actionSearch"
                android:inputType="text"
                android:maxLines="1"
                android:paddingLeft="2dp"
                android:singleLine="true"
                android:textColor="#ffffff"
                android:textColorHint="#b3ffffff" />
    
            <ImageView
                android:id="@+id/search_clear"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:paddingLeft="16dp"
                android:paddingRight="16dp"
                android:src="@drawable/ic_close_white_24dp" />
        </LinearLayout>
    </android.support.v7.widget.Toolbar>
    

    onCreate() of your Activity:

        searchContainer = findViewById(R.id.search_container);
        toolbarSearchView = (EditText) findViewById(R.id.search_view);
        searchClearButton = (ImageView) findViewById(R.id.search_clear);
    
        // Setup search container view
        try {
            // Set cursor colour to white
            // https://stackoverflow.com/a/26544231/1692770
            // https://github.com/android/platform_frameworks_base/blob/kitkat-release/core/java/android/widget/TextView.java#L562-564
            Field f = TextView.class.getDeclaredField("mCursorDrawableRes");
            f.setAccessible(true);
            f.set(toolbarSearchView, R.drawable.edittext_whitecursor);
        } catch (Exception ignored) {
        }
    
        // Search text changed listener
        toolbarSearchView.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
            }
    
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                Fragment mainFragment = getFragmentManager().findFragmentById(R.id.container);
                if (mainFragment != null && mainFragment instanceof MainListFragment) {
                    ((MainListFragment) mainFragment).search(s.toString());
                }
            }
    
            @Override
            public void afterTextChanged(Editable s) {
            }
        });
    
        // Clear search text when clear button is tapped
        searchClearButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                toolbarSearchView.setText("");
            }
        });
    
        // Hide the search view
        searchContainer.setVisibility(View.GONE);