Hardik Joshi
Hardik Joshi

Reputation: 9507

Bottom sheet scrollable content in middle in Android

I am facing 1 issue with BottomSheet in Android. What I am trying to achieve :

BottomSheet Google Direction

But I am not able to implement ListView with header and footer like google.

The main issue is ListView shows all the items and my bottom sheet not displaying footer view. I want to always visible the bottom view of my bottom sheet.

Upvotes: 0

Views: 2523

Answers (2)

David Ferrand
David Ferrand

Reputation: 5470

If the footer is your main problem here's a simple solution:

  1. Add the footer View as a direct child of your BottomSheet
<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:id="@+id/bottom_sheet"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#EECCCCCC"
            app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

        <TextView
            android:id="@+id/pinned_bottom"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#500000FF"
            android:padding="16dp"
            android:text="Bottom" />
    </FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
  1. Add a BottomSheetCallback and adjust the footer's translationY in onSlide()
BottomSheetBehavior.from(bottom_sheet).addBottomSheetCallback(
    object : BottomSheetBehavior.BottomSheetCallback() {

        override fun onSlide(bottomSheet: View, slideOffset: Float) {
            val bottomSheetVisibleHeight = bottomSheet.height - bottomSheet.top

            pinned_bottom.translationY =
                (bottomSheetVisibleHeight - pinned_bottom.height).toFloat()
        }

        override fun onStateChanged(bottomSheet: View, newState: Int) {
        }
    })

It runs smoothly because you're simply changing the translationY.

You can also use this technique to pin a View in the center of the BottomSheet:

pinned_center.translationY = (bottomSheetVisibleHeight - pinned_center.height) / 2f

I've made a sample project on GitHub to reproduce both use cases (pinned to center and bottom): https://github.com/dadouf/BottomSheetGravity

Demo

Upvotes: 4

android
android

Reputation: 3090

I have created sample example to achieve bottom sheet as google direction that will floating on any ui and working will be same as google direction swipable view. I achieved it using "ViewDragHelper" class(https://developer.android.com/reference/android/support/v4/widget/ViewDragHelper)

Here is the sample what i achieved with "ViewDragHelper" same as google direction swipable view:

enter image description here

Note: In below example, there is hard coded strings as well a single adapter taken only in swipable view class and also taken static list. Anyone can customize it with proper code format as well getter/setters. This is for example only to taught how "ViewDragHelper" works.

First create "GoogleBottomSheet" class as below:

import android.content.Context;
import android.support.v4.view.MotionEventCompat;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.ViewDragHelper;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;

import java.util.ArrayList;

public class GoogleBottomSheet extends ViewGroup {

    private final ViewDragHelper mDragHelper;
    GoogleRoutesAdapter googleRoutesAdapter;

    private View mHeaderView;
    private RecyclerView rvList;

    private float mInitialMotionX;
    private float mInitialMotionY;

    private int mDragRange;
    private int mTop;
    private float mDragOffset;


    public GoogleBottomSheet(Context context) {
        this(context, null);
    }

    public GoogleBottomSheet(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mHeaderView = findViewById(R.id.viewHeader);
        rvList = findViewById(R.id.rvList);
        LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false);
        rvList.setLayoutManager(linearLayoutManager);

        ArrayList<String> allRoutesList = new ArrayList<>();
        allRoutesList.add("47 Bourbon Li");
        allRoutesList.add("Head South");
        allRoutesList.add("Princess Street");
        allRoutesList.add("A 3-lane partially one-way street heading out of Manchester city centre");
        allRoutesList.add("Manchester Jewish Museum, \n" +
                "Peninsula Building");
        allRoutesList.add("Portland Street");
        allRoutesList.add("Quay Street");
        allRoutesList.add("Forms part of the city's historic Northern Quarter district");
        allRoutesList.add("Sackville Street Building, University of Manchester including the Godlee Observatory");
        allRoutesList.add("Turn Left on S Naper");
        allRoutesList.add("150 W-Stall");
        allRoutesList.add("Former National Westminster Bank");
        allRoutesList.add("Bradshaw, L. D.");
        allRoutesList.add("House of Commons Transport Committee");
        allRoutesList.add("A street only for Metrolink trams and previously buses which joined the street at Lower Mosley Street.");
        googleRoutesAdapter = new GoogleRoutesAdapter(getContext(), allRoutesList);
        rvList.setAdapter(googleRoutesAdapter);
    }

    public GoogleBottomSheet(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        mDragHelper = ViewDragHelper.create(this, 1f, new DragHelperCallback());
    }

    public void maximize() {
        smoothSlideTo(0f);
    }

    boolean smoothSlideTo(float slideOffset) {
        final int topBound = getPaddingTop();
        int y = (int) (topBound + slideOffset * mDragRange);

        if (mDragHelper.smoothSlideViewTo(mHeaderView, mHeaderView.getLeft(), y)) {
            ViewCompat.postInvalidateOnAnimation(this);
            return true;
        }
        return false;
    }

    private class DragHelperCallback extends ViewDragHelper.Callback {

        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return child == mHeaderView;
        }

        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            mTop = top;

            mDragOffset = (float) top / mDragRange;

            requestLayout();
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            int top = getPaddingTop();
            if (yvel > 0 || (yvel == 0 && mDragOffset > 0.5f)) {
                top += mDragRange;
            }
            mDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top);
        }

        @Override
        public int getViewVerticalDragRange(View child) {
            return mDragRange;
        }

        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            final int topBound = getPaddingTop();
            final int bottomBound = getHeight() - mHeaderView.getHeight();

            final int newTop = Math.min(Math.max(top, topBound), bottomBound);
            return newTop;
        }

    }

    @Override
    public void computeScroll() {
        if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int action = MotionEventCompat.getActionMasked(ev);

        if ((action != MotionEvent.ACTION_DOWN)) {
            mDragHelper.cancel();
            return super.onInterceptTouchEvent(ev);
        }

        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
            mDragHelper.cancel();
            return false;
        }

        final float x = ev.getX();
        final float y = ev.getY();
        boolean interceptTap = false;

        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                mInitialMotionX = x;
                mInitialMotionY = y;
                interceptTap = mDragHelper.isViewUnder(mHeaderView, (int) x, (int) y);
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                final float adx = Math.abs(x - mInitialMotionX);
                final float ady = Math.abs(y - mInitialMotionY);
                final int slop = mDragHelper.getTouchSlop();
                if (ady > slop && adx > ady) {
                    mDragHelper.cancel();
                    return false;
                }
            }
        }

        return mDragHelper.shouldInterceptTouchEvent(ev) || interceptTap;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        mDragHelper.processTouchEvent(ev);

        final int action = ev.getAction();
        final float x = ev.getX();
        final float y = ev.getY();

        boolean isHeaderViewUnder = mDragHelper.isViewUnder(mHeaderView, (int) x, (int) y);
        switch (action & MotionEventCompat.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN: {
                mInitialMotionX = x;
                mInitialMotionY = y;
                break;
            }

            case MotionEvent.ACTION_UP: {
                final float dx = x - mInitialMotionX;
                final float dy = y - mInitialMotionY;
                final int slop = mDragHelper.getTouchSlop();
                if (dx * dx + dy * dy < slop * slop && isHeaderViewUnder) {
                    if (mDragOffset == 0) {
                        smoothSlideTo(1f);
                    } else {
                        smoothSlideTo(0f);
                    }
                }
                break;
            }
        }


        return isHeaderViewUnder && isViewHit(mHeaderView, (int) x, (int) y) ||
                isViewHit(rvList, (int) x, (int) y);
    }


    private boolean isViewHit(View view, int x, int y) {
        int[] viewLocation = new int[2];
        view.getLocationOnScreen(viewLocation);
        int[] parentLocation = new int[2];
        this.getLocationOnScreen(parentLocation);
        int screenX = parentLocation[0] + x;
        int screenY = parentLocation[1] + y;
        return screenX >= viewLocation[0] && screenX < viewLocation[0] + view.getWidth() &&
                screenY >= viewLocation[1] && screenY < viewLocation[1] + view.getHeight();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int maxWidth = MeasureSpec.getSize(widthMeasureSpec);
        int maxHeight = MeasureSpec.getSize(heightMeasureSpec);

        setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
                resolveSizeAndState(maxHeight, heightMeasureSpec, 0));
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        mDragRange = getHeight() - mHeaderView.getHeight();

        mHeaderView.layout(
                0,
                mTop,
                r,
                mTop + mHeaderView.getMeasuredHeight());

        rvList.layout(
                0,
                mTop + mHeaderView.getMeasuredHeight(),
                r,
                mTop + b);
    }
}

Create xml file named as "rawitem_mapdetails.xml" for recyclerview viewholder item as below:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/mTextView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/colorPrimaryDark"
            android:text="Route 1"
            android:padding="@dimen/_10sdp"
            android:textColor="@android:color/white"
            android:textSize="@dimen/_15sdp" />

    </RelativeLayout>

</LinearLayout>

Create simple adapter named "GoogleRoutesAdapter" for recyclerview as below:

import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;

import java.util.ArrayList;

public class GoogleRoutesAdapter extends RecyclerView.Adapter<GoogleRoutesAdapter.GoogleRouteViewHolder> {

    private Context mContext;
    private ArrayList<String> allRoutesList;

    public GoogleRoutesAdapter(Context context, ArrayList<String> allRoutesList) {
        this.mContext = context;
        this.allRoutesList = allRoutesList;
    }

    @Override
    public GoogleRouteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View layoutView = LayoutInflater.from(parent.getContext()).inflate(R.layout.rawitem_mapdetails, null);
        GoogleRouteViewHolder rcv = new GoogleRouteViewHolder(layoutView);
        return rcv;
    }

    @Override
    public void onBindViewHolder(final GoogleRouteViewHolder holder, final int position) {
        holder.tvRoute.setText(allRoutesList.get(position));
    }

    @Override
    public int getItemCount() {
        return allRoutesList.size();
    }


    public class GoogleRouteViewHolder extends RecyclerView.ViewHolder {
        private TextView tvRoute;

        public GoogleRouteViewHolder(View view) {
            super(view);
            tvRoute = view.findViewById(R.id.mTextView);
            tvRoute.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    Toast.makeText(mContext, allRoutesList.get(getAdapterPosition()), Toast.LENGTH_SHORT).show();
                }
            });
        }

    }

}

Create "activity_main.xml" as below for MainActivity as below:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.viewdraghelper.GoogleBottomSheet
        android:id="@+id/my_googleBottomSheet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/viewHeader"
            android:layout_width="match_parent"
            android:background="@color/colorAccent"
            android:layout_height="@dimen/_80sdp"
            android:textSize="@dimen/_25sdp"
            android:padding="@dimen/_10sdp"
            android:textColor="@android:color/white"
            android:text="31 min (29 mi)"/>

        <android.support.v7.widget.RecyclerView
            android:id="@+id/rvList"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </com.viewdraghelper.GoogleBottomSheet>

</RelativeLayout>

Edited Answer based on requirements as below:

1. To get sliding panel at bottom/hidden as default state on view created first time

First take initOnce global boolean variable

private boolean initOnce = false;

Then change onLayout() method as below:

@Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if(!initOnce){
            initOnce = true;
            mDragRange = getHeight() - mHeaderView.getHeight();
            mHeaderView.layout(
                    0,
                    b - mHeaderView.getMeasuredHeight(),
                    r,
                    b);
        }else {
            mDragRange = getHeight() - mHeaderView.getHeight();

            mHeaderView.layout(
                    0,
                    mTop,
                    r,
                    mTop + mHeaderView.getMeasuredHeight());

            rvList.layout(
                    0,
                    mTop + mHeaderView.getMeasuredHeight(),
                    r,
                    mTop + b);
        }
    }

Now all done! As i stated above that this is to only taught how "ViewDragHelper" works thats why we don't have to do anything in MainActivity right now because all adapter logic resides in "GoogleBottomSheet" class. I have also take one simple recyclerview item click so anyone can have better idea that other ui will work same as its own behaviour. We can also customize by putting any ui in "GoogleBottomSheet".

Hope it helps! Happy Coding :)

Upvotes: 1

Related Questions