I have an ImageView in the top of the layout and a RecyclerView below it; That's all I need in my custom Behavior step 1; But, it doesn't seem to work well.
Here is the code:
Behavior.java
public class Depency extends CoordinatorLayout.Behavior<RecyclerView> {
private String TAG = "tag";
public Depency() {
}
public Depency(Context context, AttributeSet attrs) {
super(context, attrs);
}
#Override
public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View
dependency) {
int delta = (int) (dependency.getTranslationY() + dependency.getBottom());
delta -= child.getTop();
child.offsetTopAndBottom(delta);
Log.i(TAG,
"onDependentViewChanged: " + delta + "," + child.getTop() + dependency.getClass().getSimpleName());
return true;
}
#Override
public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
return dependency instanceof AppCompatImageView;
}
}
layout.xml
<android.support.design.widget.CoordinatorLayout
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"
app:layout_behavior="#string/appbar_scrolling_view_behavior"
tools:context="com.example.kenchan.fullypjo.MainActivity"
tools:showIn="#layout/activity_main">
<android.support.v7.widget.AppCompatImageView
android:id="#+id/imageView"
android:layout_width="match_parent"
android:layout_height="150dp"
android:scaleType="centerCrop"
android:src="#drawable/ic_girl"/>
<android.support.v7.widget.RecyclerView
android:id="#+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="com.example.kenchan.fullypjo.view.Depency"/>
</android.support.design.widget.CoordinatorLayout>
The preview in xml:
seems work fine.
But when I run the project on my cell phone , I get wrong:
Could somebody help me or advise me with my code ?
The problem has bothered me for a while;
I tried to read source code in android.support.design.widget.AppBarLayout$ScrollingViewBehavior
and I found the code logic just at different nothing:
#Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child,
View dependency) {
offsetChildAsNeeded(parent, child, dependency);
return false;
}
private void offsetChildAsNeeded(CoordinatorLayout parent, View child, View dependency) {
final CoordinatorLayout.Behavior behavior =
((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
if (behavior instanceof Behavior) {
// Offset the child, pinning it to the bottom the header-dependency, maintaining
// any vertical gap and overlap
final Behavior ablBehavior = (Behavior) behavior;
ViewCompat.offsetTopAndBottom(child, (dependency.getBottom() - child.getTop())
+ ablBehavior.mOffsetDelta
+ getVerticalLayoutGap()
- getOverlapPixelsForOffset(dependency));
}
}
RecyclerView is at top z-index, that's why you are seeing it as overlayed over the ImageView. In the preview you see it with only see 9 items.
You need to change RecyclerView top when inside your Behavior's onLayoutChild() method, to the bottom of the ImageView. This method gives you a chance to lay out the child yourself, instead of allowing CoordinatorLayout to do it deliberately. If you return true, CoordinatorLayout will not attempt to layout that view.
Related
I need to create a horizontal list of items that only displays fully visible items.
But as you can see, my recycler view show a particular element. I use a horizontal LinearLayoutManager.
I add 10 elements, but recycler view has room only for 3. I need to show only 3, but it always show me 3 and particular element.
<androidx.constraintlayout.widget.ConstraintLayout 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">
<TextView
android:id="#+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/recycler"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintEnd_toStartOf="#+id/textView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
My item layout:
<LinearLayout
android:id="#+id/itemLayout"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:id="#+id/tvAnimalName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ASDAS"
android:background="#color/colorAccent"
android:padding="10dp"
android:textSize="17sp"/>
</LinearLayout>
Adapter and activity are plain.
How can I show only visible 3 items?
Edit.
I must to disable scroll. So i am using:
layoutManager = new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) {
#Override
public boolean canScrollHorizontally() {
return false;
}
};
Edit 2. These methods show -1 always:
int findLastVisibleItemPosition();
int findLastCompletelyVisibleItemPosition();
class HideLastDecorator() : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
val count = parent.childCount
for (i in 0 until count) {
parent.getChildAt(i).visibility = if (count == i - 1) View.INVISIBLE else View.VISIBLE
}
}
}
and add it to your recyclerView Decorations
appsRecyclerView.addItemDecoration(HideLastDecorator())
Sorry for Kotlin :)
View.INVISIBLE is important, because if the View becomes GONE, it will be removed from the measuring of the RecyclerView's content and the new ViewHolder would be added.
I prefer to work careful with OnClickListener if any is set for the ViewHolder's content.
Below code will work for you. A little explanation: Extend RecyclerView and override onLayout() method. Once RecyclerView is ready iterate through all visible (on-screen) children of RecyclerView and apply your logic. In our case we'll draw BounddingBox for every nth child and RecyclerView. If child's bounds lie inside RecyclerView's bounds then show that child otherwise set visibility to GONE/INVISIBLE.
public class CustomRecyclerView extends RecyclerView {
Rect recyclerViewBounds = new Rect();
Rect currentChildViewBounds = new Rect();
public CustomRecyclerView(#NonNull Context context) {
super(context);
}
public CustomRecyclerView(#NonNull Context context, #Nullable AttributeSet attrs) {
super(context, attrs);
}
public CustomRecyclerView(#NonNull Context context, #Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
#Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
recyclerViewBounds.set(l, t, r, b);
for (int i = 0; i < getChildCount(); i++) {
View currentChild = getChildAt(i);
currentChildViewBounds.set(currentChild.getLeft(), currentChild.getTop(), currentChild.getRight(), currentChild.getBottom());
currentChild.setVisibility(recyclerViewBounds.contains(currentChildViewBounds) ? VISIBLE : GONE); // or INVISBLE instead of GONE
}
}
}
And most importantly: In your xml file use com.your.packagename.CustomRecyclerView instead of androidx.recyclerview.widget.RecyclerView.
NOTE: Please refrain from any object initialization inside onLayout(). What I mean is don't move the object initializations inside onLayout() to make it "fancier".
None of the proposed answer worked as expected, so there ItemDecoration that i made, it checks if view completely visible in layout manager, and hide rest of views
class HideNotFullyVisibleDecorator : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
val count = parent.childCount
for (i in 0 until count) {
val currentChild = parent.getChildAt(i)
currentChild.visibility =
if (parent.layoutManager?.isViewPartiallyVisible(currentChild, true, false) == true)
View.VISIBLE
else
View.GONE
}
}
}
Usage: recycler.addItemDecoration(HideNotFullyVisibleDecorator())
Also in my case I disabled scrolling for recycler
Recycler view is a Scrollable container which holds viewholders and recycles on scroll-up and down,
So it will display as much data as possible on screen, and that fourth half-visible item that you've shown in screenshot is just default behaviour of every scrollable view in android.
You have to customize your viewholders to adjust accordingly on runtime so that only fully visible items should be rendered.
You can do something like:
val availableWidth = screenWidth - (textViewWidth)
val itemWidth = (availableWidth / 3)
How can achieve pinch zoom behavior like Gmail app? I've put header container in ScrollView followed by WebView. Seems It's very complex behavior.
Here is without zoom.
When we pinch Webview upper container scrolled up as per zoom:
So far here is my initials:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#color/white">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#color/white"
android:fitsSystemWindows="true">
<android.support.design.widget.AppBarLayout
android:id="#+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.v7.widget.Toolbar
android:id="#+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="#color/white">
</android.support.v7.widget.Toolbar>
</android.support.design.widget.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="#+id/appbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="#color/colorPrimary"></FrameLayout>
<WebView
android:id="#+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#color/white"
android:scrollbars="none" />
</LinearLayout>
</ScrollView>
</RelativeLayout>
</FrameLayout>
GMail uses a Chrome WebView with pinch zoom enabled. the zoom only applies to the single thread view. WebSettings setBuiltInZoomControls() is by default false and setDisplayZoomControls() is by default true. by changing both, the zoom works and there are no zoom controls being displayed:
webview.getSettings().setBuiltInZoomControls(true);
webview.getSettings().setDisplayZoomControls(false);
and that toolbar is a transparently styled ActionBar, with style windowActionBarOverlay set true:
Flag indicating whether this window's Action Bar should overlay application content.
the ActionBar's bottom shadow is being removed in the top-most scroll position. this one listens for vertical scroll events and not for any scaling gestures. this effect works about like this (initially that shadow has to be hidden):
webView.setOnScrollChangeListener(new View.OnScrollChangeListener() {
#Override
public void onScrollChange(View view, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
if(scrollY == 0) {
/* remove the ActionBar's bottom shadow */
} else {
/* apply the ActionBar's bottom shadow */
}
}
}
depending how often the OnScrollChangeListener is being triggered, even checking for scrollY == 0 and scrollY == 1 might already suffice to switch the shadow on and off.
when scaling, this seems to be a ScaleGestureDetector.SimpleOnScaleGestureListener (see the docs), where .getScaleFactor() is being used to animate the secondary "toolbar" vertical top position, which then shoves it outside of the visible view-port. and this secondary "toolbar" appears to be a nested vertical DrawerLayout - which cannot be manually moved - that's why it moves that smooth... a DrawerLayout is not limited to be a horizontal drawer; and I think this is the answer.
Edit: I'd relatively certain now, that this is AndroidX with MDC Motion.
I think I understood your question. You want to push the subject line in the upward direction and the other emails in the downward direction when an email is being expanded. I tried to implement the idea of showing an email in the Gmail app. I think I am very close to the solution as the pushing is not smooth enough. However, I wanted to share the answer here to present my thought about your question.
I have created a GitHub repository from where you can see my implementation. I have added a readme there as well to explain the overall idea.
I tried to implement the whole thing using a RecyclerView have different ViewTypes. I have added an adapter which is like the following.
public class RecyclerViewWithHeaderFooterAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int HEADER_VIEW = 1;
private static final int GROUPED_VIEW = 2;
private static final int EXPANDED_VIEW = 3;
private ArrayList<Integer> positionTracker; // Take any list that matches your requirement.
private Context context;
private ZoomListener zoomListener;
// Define a constructor
public RecyclerViewWithHeaderFooterAdapter(Context context, ZoomListener zoomListener) {
this.context = context;
this.zoomListener = zoomListener;
positionTracker = Utilities.populatePositionsWithDummyData();
}
// Define a ViewHolder for Header view
public class HeaderViewHolder extends ViewHolder {
public HeaderViewHolder(View itemView) {
super(itemView);
itemView.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View v) {
// Do whatever you want on clicking the item
}
});
}
}
// Define a ViewHolder for Expanded view
public class ExpandedViewHolder extends ViewHolder {
public ExpandedViewHolder(View itemView) {
super(itemView);
itemView.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View v) {
// Do whatever you want on clicking the item
}
});
}
}
// Define a ViewHolder for Expanded view
public class GroupedViewHolder extends ViewHolder {
public GroupedViewHolder(View itemView) {
super(itemView);
itemView.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View v) {
// Do whatever you want on clicking the item
}
});
}
}
// And now in onCreateViewHolder you have to pass the correct view
// while populating the list item.
#Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v;
if (viewType == EXPANDED_VIEW) {
v = LayoutInflater.from(context).inflate(R.layout.list_item_expanded, parent, false);
ExpandedViewHolder vh = new ExpandedViewHolder(v);
return vh;
} else if (viewType == HEADER_VIEW) {
v = LayoutInflater.from(context).inflate(R.layout.list_item_header, parent, false);
HeaderViewHolder vh = new HeaderViewHolder(v);
return vh;
} else {
v = LayoutInflater.from(context).inflate(R.layout.list_item_grouped, parent, false);
GroupedViewHolder vh = new GroupedViewHolder(v);
return vh;
}
}
// Now bind the ViewHolder in onBindViewHolder
#Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
try {
if (holder instanceof ExpandedViewHolder) {
ExpandedViewHolder vh = (ExpandedViewHolder) holder;
vh.bindExpandedView(position);
} else if (holder instanceof GroupedViewHolder) {
GroupedViewHolder vh = (GroupedViewHolder) holder;
} else if (holder instanceof HeaderViewHolder) {
HeaderViewHolder vh = (HeaderViewHolder) holder;
}
} catch (Exception e) {
e.printStackTrace();
}
}
// Now the critical part. You have return the exact item count of your list
// I've only one footer. So I returned data.size() + 1
// If you've multiple headers and footers, you've to return total count
// like, headers.size() + data.size() + footers.size()
#Override
public int getItemCount() {
return DEMO_LIST_SIZE; // Let us consider we have 6 elements. This can be replaced with email chain size
}
// Now define getItemViewType of your own.
#Override
public int getItemViewType(int position) {
if (positionTracker.get(position).equals(HEADER_VIEW)) {
// This is where we'll add the header.
return HEADER_VIEW;
} else if (positionTracker.get(position).equals(GROUPED_VIEW)) {
// This is where we'll add the header.
return GROUPED_VIEW;
} else if (positionTracker.get(position).equals(EXPANDED_VIEW)) {
// This is where we'll add the header.
return EXPANDED_VIEW;
}
return super.getItemViewType(position);
}
// So you're done with adding a footer and its action on onClick.
// Now set the default ViewHolder for NormalViewHolder
public class ViewHolder extends RecyclerView.ViewHolder {
// Define elements of a row here
public ViewHolder(View itemView) {
super(itemView);
// Find view by ID and initialize here
}
public void bindExpandedView(final int position) {
// bindExpandedView() method to implement actions
final WebView webView = itemView.findViewById(R.id.email_details_web_view);
webView.getSettings().setBuiltInZoomControls(true);
webView.getSettings().setDisplayZoomControls(false);
webView.loadUrl("file:///android_asset/sample.html");
webView.setOnScrollChangeListener(new View.OnScrollChangeListener() {
#Override
public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
zoomListener.onZoomListener(position);
}
});
}
}
}
And the expanded list item contains a WebView which has a wrapper which is wrap_content. You will find the following layout in the list_item_expanded.xml.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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">
<WebView
android:id="#+id/email_details_web_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#android:color/white"
android:scrollbars="none"
tools:ignore="WebViewLayout" />
</RelativeLayout>
I tried to add some dummy data for the experiment and hence the Utility class was written. The RecyclerView is set to have a reverse layout as this is the common expectation of showing a conversation in a RecyclerView.
The key idea is to scrollToPosition when the WebView is being expanded. So that it feels like the items are push upwards and downwards to accommodate the expansion. Hope you get the idea.
I am adding some screenshots here to give you an idea about what I could achieve so far.
Please note that the pushing mechanism is not smooth. I will be working on this. However, I thought I should post it here as this might help you in your thinking. I would like to suggest you clone the repository and run the application to check the overall implementation. Let me know if there is any feedback.
I tried to implement this behaviour using webview.setBuiltInZoomControls() and ScaleGestureDetector by overriding WebView and feeding all motion events to detector. It works, but scale detector and zoom work a bit different and UX turns out to be terrible.
If you look closely at Gmail zoom implementation and a webview zoom you will see they are different. I believe the Gmail zoom is based on view.setScaleX() and view.setScaleY(). You can get a basic behaviour by subclassing WebView and following this guide. You may also need to call view.setPivotX() and view.setPivotY(). Gmail implementation is more complex since it has scroll and seems to scroll the content up while you zoom in. You can try to use some library that implements the zoomable container and supports scrolling, like this one. However I was unable to make it work properly with WebView.
Overall it's a complex task and you have to play with the implementation yourself to make some compromises and get a similar but decent UX.
I am recently using CustomBottomSheetBehavior to make an googlemaps like bottom sheet behavior and it works great. I have only one problem.please look at this image
If I use it in a scrolling activity the content of tool-bar covers my list box. so I ahve to add margin-top to my list view. It works but when I draw bottomsheet up toolbar goes up and behind it, there is an empty space. This is because I have added some margin top to make my list's top visible. Is there any way to connect list's margin top to the amount of moving bottom-sheet and when It moves up decrease margin value to and when it moves down increase it?or is there any better way ?
It seems I have to develope my own TopMarginBehavior for this job but I have no idea how to do it.
thanks
Create your own class related to the behavior you want (MarginTopBehavior)
Extends it from CoordinatorLayout.Behavior
Now you have to focus on 2 methods: layoutDependsOn and onDependentViewChanged. With the first one you are selecting the view that your MarginTopBehavior is following, in this case is a NestedScrollView. With the second one you are reacting (the magic!) when the scroll get moved.
At this point you get this:
public class MarginTopBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
private FrameLayout.LayoutParams mLayoutParams;
public MarginTopBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
#Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
return dependency instanceof NestedScrollView;
}
#Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
}
}
The logic to be applied in onDependentViewChanged is just this:
* Define the cap (min/max margin value) and controls when the margin value has reached one of those cap.
* Update margin value while the values are between the caps. In this point you have to implement an algorithm about what you want (parallax, linear, etc). That is what I'm calling THE_MAGIC_ECC in the next code:
public class MarginTopBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
/**
* Params of the component you want to modify the margin
*/
private FrameLayout.LayoutParams mLayoutParams;
/**
* Used to access DIMENS in your project
*/
private Context mContext;
private int mMinYvalue;
private int mMaxYValue;
public MarginTopBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
}
#Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
return dependency instanceof NestedScrollView;
}
#Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
if (mLayoutParams == null) {
mLayoutParams = (FrameLayout.LayoutParams) child.getLayoutParams();
}
if (dependency.getY() <= mMinYvalue) {
mLayoutParams.setMargins(0, 0, 0, 0);
child.setLayoutParams(mLayoutParams);
return true;
}
else if (dependency.getY() > mMinYvalue && dependency.getY() <= mMaxYValue) {
int THE_MAGIC_ECC = 1 + 2 + 3;
mLayoutParams.setMargins(0, 0, 0, THE_MAGIC_ECC );
child.setLayoutParams(mLayoutParams);
return true;
}
else {
mLayoutParams.setMargins(0, 0, 0, 100);
child.setLayoutParams(mLayoutParams);
return true;
}
}
}
My layout is absolutely identical with this tutorial. But in this tutorial we use app:layout_behavior="pl.michalz.hideonscrollexample.ScrollingFABBehavior"
in Fab XML, so SnackBar covers the Fab. Without this code Fab does not move followed by RecyclerView. How to show SnackBar correctly?
Snackbar.make(getActivity().findViewById(R.id.coordinatorLayout),
adapter.getNewsList().get(position).getTitle(), Snackbar.LENGTH_LONG).show();
The example you follow is pretty unlucky. The default behavior of FloatingActionButton within CoordinatorLayout is to move up when you display SnackBar. Since this code overrides the Behavior you lose this feature because the methods never call their super class implementations. Clearly the author have not thought about this. However, you can modify the ScrollingFABBehavior to extend the original Behavior and thus support SnackBar:
public class ScrollingFABBehavior extends FloatingActionButton.Behavior {
private int toolbarHeight;
public ScrollingFABBehavior(Context context, AttributeSet attrs) {
super();
this.toolbarHeight = Utils.getToolbarHeight(context);
}
#Override
public boolean layoutDependsOn(CoordinatorLayout parent, FloatingActionButton fab, View dependency) {
return super.layoutDependsOn(parent, fab, dependency) || (dependency instanceof AppBarLayout);
}
#Override
public boolean onDependentViewChanged(CoordinatorLayout parent, FloatingActionButton fab, View dependency) {
boolean returnValue = super.onDependentViewChanged(parent, fab, dependency);
if (dependency instanceof AppBarLayout) {
CoordinatorLayout.LayoutParams lp = (CoordinatorLayout.LayoutParams) fab.getLayoutParams();
int fabBottomMargin = lp.bottomMargin;
int distanceToScroll = fab.getHeight() + fabBottomMargin;
float ratio = (float)dependency.getY()/(float)toolbarHeight;
fab.setTranslationY(-distanceToScroll * ratio);
}
return returnValue;
}
}
This is actually the class from the example's github repository, I have found it just after I coded the same myself and wanted to test it. They only forgot to update the blog post :-/
Have you tried this?
...
View view = inflater.inlfate(R.layout.my_layout, parent, false);
...
Snackbar.make(view.findViewById(R.id.fab),
adapter.getNewsList().get(position).getTitle(), Snackbar.LENGTH_LONG).show();
...
return view;
I assume that you call above code in Fragment, so I added view variable to call the findViewById() method.
I have some problem trying to control touch event propagation within my RecycleView. So I have a RecyclerView populating a set of CardViews imitating a card stack (so they overlap each other with a certain displacement, though they have different elevation value). My current problem is that each card has a button and since relative card displacement is smaller than height of the button it results in the situation that buttons are overlapping and whenever a touch event is dispatched it starts propagating from the bottom of view hierarchy (from children with highest child number).
According to articles I read (this, this and also this video) touch propagation is dependent on the order of views in parent view, so touch will first be delivered to the child with highest index, while I want the touch event to be processed only by touchables of the topmost view and RecyclerView (it also has to process drag and fling gestures). Currently I am using a fallback with cards that are aware of their position within parent's view hierarchy to prevent wrong children from processing touches but this is really ugly way to do that. My assumption is that I have to override dispatchTouchEvent method of the RecyclerView and properly dispatch a touch event only to topmost child. However, when I tried this way of doing that (which is also kind of clumsy):
#Override
public boolean dispatchTouchEvent(MotionEvent ev) {
View topChild = getChildAt(0);
for (View touchable : topChild.getTouchables()) {
int[] location = new int[2];
touchable.getLocationOnScreen(location);
RectF touchableRect = new RectF(location[0],
location[1],
location[0] + touchable.getWidth(),
location[1] + touchable.getHeight());
if (touchableRect.contains(ev.getRawX(), ev.getRawY())) {
return touchable.dispatchTouchEvent(ev);
}
}
return onTouchEvent(ev);
}
Only DOWN event was delivered to the button within a card (no click event triggered). I will appreciate any advice on the way of reversing touch event propagation order or on delegating of touch event to a specific View. Thank you very much in advance.
EDIT: This is the screenshot of how the example card stack is looking like
Example adapter code:
public class TestAdapter extends RecyclerView.Adapter {
List<Integer> items;
public TestAdapter(List<Integer> items) {
this.items = items;
}
#Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.test_layout, parent, false);
return new TestHolder(v);
}
#Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
TestHolder currentHolder = (TestHolder) holder;
currentHolder.number.setText(Integer.toString(position));
currentHolder.tv.setTag(position);
currentHolder.tv.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View v) {
int pos = (int) v.getTag();
Toast.makeText(v.getContext(), Integer.toString(pos) + "clicked", Toast.LENGTH_SHORT).show();
}
});
}
#Override
public int getItemCount() {
return items.size();
}
private class TestHolder extends RecyclerView.ViewHolder {
private TextView tv;
private TextView number;
public TestHolder(View itemView) {
super(itemView);
tv = (TextView) itemView.findViewById(R.id.click);
number = (TextView) itemView.findViewById(R.id.number);
}
}
}
and an example card layout:
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_margin="16dp"
android:textSize="24sp"
android:id="#+id/number"/>
<TextView android:layout_width="wrap_content"
android:layout_height="64dp"
android:gravity="center"
android:layout_alignParentBottom="true"
android:layout_alignParentLeft="true"
android:layout_margin="16dp"
android:textSize="24sp"
android:text="CLICK ME"
android:id="#+id/click"/>
</RelativeLayout>
</android.support.v7.widget.CardView>
And here is the code that I am using now to solve the problem (this approach I do not like, I want to find better way)
public class PositionAwareCardView extends CardView {
private int mChildPosition;
public PositionAwareCardView(Context context) {
super(context);
}
public PositionAwareCardView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public PositionAwareCardView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setChildPosition(int pos) {
mChildPosition = pos;
}
#Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// if it's not the topmost view in view hierarchy then block touch events
return mChildPosition != 0 || super.onInterceptTouchEvent(ev);
}
#Override
public boolean onTouchEvent(MotionEvent event) {
return false;
}
}
EDIT 2: I've forgotten to mention, this problem is present only on pre-Lolipop devices, it seems that starting from Lolipop, ViewGroups also take elevation into consideration while dispatching touch events
EDIT 3: Here is my current child drawing order:
#Override
protected int getChildDrawingOrder(int childCount, int i) {
return childCount - i - 1;
}
EDIT 4: Finally I was able to fix the problem thanks to user random, and this answer, the solution was extremely simple:
#Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!onInterceptTouchEvent(ev)) {
if (getChildCount() > 0) {
final float offsetX = -getChildAt(0).getLeft();
final float offsetY = -getChildAt(0).getTop();
ev.offsetLocation(offsetX, offsetY);
if (getChildAt(0).dispatchTouchEvent(ev)) {
// if touch event is consumed by the child - then just record it's coordinates
x = ev.getRawX();
y = ev.getRawY();
return true;
}
ev.offsetLocation(-offsetX, -offsetY);
}
}
return super.dispatchTouchEvent(ev);
}
CardView uses elevation API on L and before L, it changes the shadow size. dispatchTouchEvent in L respects Z ordering when iterating over children which doesn't happen in pre L.
Looking at the source code:
Pre Lollipop
ViewGroup#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = customOrder ?
getChildDrawingOrder(childrenCount, i) : i;
final View child = children[childIndex];
...
Lollipop
ViewGroup#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
// Check whether any clickable siblings cover the child
// view and if so keep track of the intersections. Also
// respect Z ordering when iterating over children.
ArrayList<View> orderedList = buildOrderedChildList();
final boolean useCustomOrder = orderedList == null
&& isChildrenDrawingOrderEnabled();
final int childCount = mChildrenCount;
for (int i = childCount - 1; i >= 0; i--) {
final int childIndex = useCustomOrder
? getChildDrawingOrder(childCount, i) : i;
final View sibling = (orderedList == null)
? mChildren[childIndex] : orderedList.get(childIndex);
// We care only about siblings over the child
if (sibling == child) {
break;
}
...
The child drawing order can be overridden with custom child drawing order in a ViewGroup, and with setZ(float) custom Z values set on Views.
You might want to check custom child drawing order in a ViewGroup but I think your current fix for the problem is good enough.
Did you try to set your topmost view as clickable ?
button.setClickable(true);
If this attribute is not set (as default) in the View XML it propagate the click event upwards.
But if you set it on the topmost view as true, it shouldn't propagate any event on any other view.