I'm having an unusual error. I have this inside a custom viewgroup. The method receives a view and add it to the layout but i keep getting the same error:
if((ViewGroup)view.getParent() != null){
((ViewGroup)view.getParent()).removeView(view);
}
addView(view); <--- Breakpoints puts the error on this line
The error is:
java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
Using breakpoints around this shows that "view" even after calling removeView onthe parent keep a reference to its parent..
Some people proposed using a runnable to wait a few seconds before adding it to the view. I havent tried this because it seems more a hack than a solution.. Either way i hope someone may be able to help
Thanks!
PS: Not that it should matter but the parent of this view i'm adding is a custom grid layout i made and its parent is a viewpager.
Edit:
I did a little more breakpoints and debugging and from the looks of it the grid effectively remove the view from its child list (debug) but the child view keeps a reference to that same grid in its mParent variable (debug). How is this possible
EDIT:
In activity:
Button button = new Button(mContext);
button.setOnClickListener(mClickListener);
(...)
Random random = new Random();
button.setText(random.nextInt(9999) + " ");
mCurrentGridLayout.addCustomView(button);
In CustomGridLayout viewgroup class:
public void addCustomView(View view){
if((ViewGroup)view.getParent() != null){
((ViewGroup)view.getParent()).removeView(view);
}
addView(view);
}
I had that same issue when trying to create a custom banner. I believe it's because of animation during layout, that's why a delay could work. In my case, I made a custom viewgroup class to eliminate the animation delay:
private class BannerLayout extends LinearLayout {
public BannerLayout(Context context) {
super(context);
}
#Override
protected void removeDetachedView(View child, boolean animate) {
super.removeDetachedView(child, false);
}
}
Once I did this, everything worked as expected.
Hope it helps!
I had the same problem, and the answer from Iree really helped me. The cause was the animation for the layout transition, but if i set it its value null i will lose my transition animation. So what i did is add a layout transition listener, that way you can listener when the transition is done, and then add the view to its new parent.
Using Java
LayoutTransition layoutTransition = ((ViewGroup)view.getParent()).getLayoutTransition();
layoutTransition.addTransitionListener(new TransitionListener(){
#Override
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
}
#Override
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
// now you can add the same view to another parent
addView(view);
}
});
Using Kotlin
val layoutTransition = (view.parent as ViewGroup).layoutTransition
layoutTransition.addTransitionListener(object : LayoutTransition.TransitionListener {
override fun startTransition(transition: LayoutTransition?,container: ViewGroup?,view: View?,transitionType: Int) {
}
override fun endTransition(transition: LayoutTransition?,container: ViewGroup?,view: View?,transitionType: Int) {
// now you can add the same view to another parent
addView(view)
}
})
In case you have to do with viewGroups that have layoutTransition or not you can do something like this:
/**
* When we don't have [LayoutTransition] onEnd is called directly
* Or
* function [onEnd] will be called on endTransition and when
* the parent is the same as container and view parent is null,
* and will remove also the transition listener
*/
private fun doOnParentRemoved(parent: ViewGroup, onEnd: () -> Unit) {
val layoutTransition = parent.layoutTransition
if (layoutTransition == null) {
onEnd.invoke()
return
}
val weakListener = WeakReference(onEnd)
layoutTransition.addTransitionListener(object : LayoutTransition.TransitionListener {
override fun startTransition(
transition: LayoutTransition?,
container: ViewGroup?,
view: View?,
transitionType: Int
) {
}
override fun endTransition(
transition: LayoutTransition?,
container: ViewGroup?,
view: View?,
transitionType: Int
) {
transition?.removeTransitionListener(this)
weakListener.get()?.invoke()
}
})
}
This is how you can use it:
sourceLayout.removeView(textView)
doOnParentRemoved(sourceLayout) {
// do your stuff when view has no parent
// add more logic to check is your view who called it in case of multiple views
}
I would suggest to double check your implementation as it can happen sometimes endTransition is not guaranteed to be called or animations can stop in middle. In my case I have used it in drag drop actions
Related
The fragment has several CardViews. I've made an utility class that contains the next method:
public void applyAnimationToEachView(#NonNull Collection<View> views,
#NonNull AnimationSet animationSet,
long offset,
boolean offsetSequentially) {
int i = 0;
for (View view: views) {
view.setAnimation(animationSet);
if (offsetSequentially)
view.getAnimation().setStartOffset(offset * i);
else view.getAnimation().setStartOffset(offset);
view.getAnimation().start();
i++;
}
}
I call the method this way:
override fun onHiddenChanged(hidden: Boolean) {
if (!hidden) AnimationManager().applyAnimationToEachView(
visibleCards as Collection<View>,
getAnimationSet(),
100,
true)
}
However, when I call the method, it sums up the offsets and shows the animation inconsequentially. When I do the same thing inside of the fragment, it works as intended:
var i : Long = 0
if (!hidden)
for (cardView in visibleCards) {
cardView.animation = getAnimationSet()
cardView.animation.startOffset = 100 * i
i = i.inc()
}
What's the reason behind this behavior? And can I somehow hide this functionality inside of my utility class?
That's because you're passing the same instance of AnimationSet to each View and then retrieve it with view.getAnimation(). Thus its start offset increases on each loop iteration. In order to make it work, you'll need to create a new Animation for each View.
I have a logo view, which is a full screen fragment containing single ImageView.
I have to perform some operations after the logo image is completely visible.
Following code is used to invoke the special task
#Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
ImageView logoImageMaster = new ImageView(getContext());
//logoImageMaster.setImageResource(resID); //even after removing this, i am getting the callback twice
try {
// get input stream
InputStream ims = getActivity().getAssets().open("product_logo.png");
// load image as Drawable
Drawable d = Drawable.createFromStream(ims, null);
// set image to ImageView
logoImageMaster.setImageDrawable(d);
}
catch(IOException ex) {
}
logoImageMaster.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
#Override
public void onGlobalLayout() { //FIXME get called twice. Check this out, no info to distinguish first from second
// Log.e("PANEL", "onGlobalLayout of Logo IV ---------------------------------");
activityInterface.doSpecialLogic();
}
});
return logoImageMaster;
}
My exact problem is, onGlobalLayout is called twice for this view hierarchy.
I know that onGlobalLayout is invoked in performTraversal of View.java hence this is expected.
For my use case of Single parent with Single child view, I want to distinguish the view attributes such that doSpecialLogic is called once[onGlobalLayout is called twice] , after the logo image is completely made visible.
Please suggest some ideas.
OnGlobalLayoutListener gets called every time the view layout or visibility changes. Maybe you reset the views in your doSpecialLogic call??
edit
as #Guille89 pointed out, the two set calls cause onGlobalLayout to be called two times
Anyhow, if you want to call OnGlobalLayoutListener just once and don't need it for anything else, how about removing it after doSpecialLogic() call??
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
//noinspection deprecation
logoImageMaster.getViewTreeObserver().removeGlobalOnLayoutListener(this);
} else {
logoImageMaster.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
activityInterface.doSpecialLogic();
It seems to be called one time for each set done over the imageView
logoImageMaster.setImageResource(resID);
logoImageMaster.setImageDrawable(d);
You should Try using kotlin plugin in android
This layout listener is usually used to do something after a view is measured, so you typically would need to wait until width and height are greater than 0. And we probably want to do something with the view that called it,in your case
Imageview
So generified the function so that it can be used by any object that extends View and also be able to access to all its specific functions and properties from the function
[kotlin]
inline fun <T: View> T.afterMeasured(crossinline f: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (measuredWidth > 0 && measuredHeight > 0) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
}
})
}
[/kotlin]
Note:
make sure that ImageView is described properly in the layout. That is its layout_width and layout_height must not be wrap_content. Moreover, other views must not result in this ImageView has 0 size.
I create a BottomSheetDialogFragment and I want to adjust it's maximum expanded height. How can I do that? I can retrieve the BottomSheetBehaviour but all I can find is a setter for the peek height but nothing for the expanded height.
public class DialogMediaDetails extends BottomSheetDialogFragment
{
#Override
public void setupDialog(Dialog dialog, int style)
{
super.setupDialog(dialog, style);
View view = View.inflate(getContext(), R.layout.dialog_media_details, null);
dialog.setContentView(view);
...
View bottomSheet = dialog.findViewById(R.id.design_bottom_sheet);
BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheet);
behavior.setPeekHeight(...);
// how to set maximum expanded height???? Or a minimum top offset?
}
}
EDIT
Why do I need that? Because I show a BottomSheet Dialog in a full screen activity and it looks bad if the BottomSheet leaves a space on top...
The height is being wrapped because the inflated view is added to the FrameLayout which has layout_height=wrap_content. See FrameLayout (R.id.design_bottom_sheet) at https://github.com/dandar3/android-support-design/blob/master/res/layout/design_bottom_sheet_dialog.xml.
The class below makes the bottom sheet full screen, background transparent, and fully expanded to the top.
public class FullScreenBottomSheetDialogFragment extends BottomSheetDialogFragment {
#CallSuper
#Override
public void onViewCreated(View view, #Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
ButterKnife.bind(this, view);
}
#Override
public void onStart() {
super.onStart();
Dialog dialog = getDialog();
if (dialog != null) {
View bottomSheet = dialog.findViewById(R.id.design_bottom_sheet);
bottomSheet.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
}
View view = getView();
view.post(() -> {
View parent = (View) view.getParent();
CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) (parent).getLayoutParams();
CoordinatorLayout.Behavior behavior = params.getBehavior();
BottomSheetBehavior bottomSheetBehavior = (BottomSheetBehavior) behavior;
bottomSheetBehavior.setPeekHeight(view.getMeasuredHeight());
((View)bottomSheet.getParent()).setBackgroundColor(Color.TRANSPARENT)
});
}
}
--- EDIT Aug 30, 2018 ---
I realized a year later that the background was colored on the wrong view. This dragged the background along with the content while a user was dragging the dialog.
I fixed it so that the parent view of the bottom sheet is colored.
I found a much simpler answer; in your example where you obtain the FrameLayout for the bottom sheet using this code
View bottomSheet = dialog.findViewById(R.id.design_bottom_sheet);
you can then set the height on the layout params for that View to whatever height you want to set the expanded height to.
bottomSheet.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
BIG UPDATE
Avoiding duplicated code I'm giving a link to the full answer in where you can find all the explanations about how to get full behavior like Google Maps.
I want to adjust its maximum expanded height. How can I do that?
Both BottomSheet and BottomSheetDialogFragment use a BottomSheetBehavior that you can found in Support Library 23.x
That Java class has 2 different uses for mMinOffset, one of them is used to define the area of the parent it will use to draw his content (maybe a NestedScrollView). And the other use is for defining the expanded anchor point, I mean, if you slide it up to form STATE_COLLAPSEDit will animate your BottomSheetuntil he reached this anchor point BUT if you can still keep sliding up to cover all parent height (CoordiantorLayout Height).
If you took a look at BottomSheetDialog you will see this method:
private View wrapInBottomSheet(int layoutResId, View view, ViewGroup.LayoutParams params) {
final CoordinatorLayout coordinator = (CoordinatorLayout) View.inflate(getContext(),
android.support.design.R.layout.design_bottom_sheet_dialog, null);
if (layoutResId != 0 && view == null) {
view = getLayoutInflater().inflate(layoutResId, coordinator, false);
}
FrameLayout bottomSheet = (FrameLayout) coordinator.findViewById(android.support.design.R.id.design_bottom_sheet);
BottomSheetBehavior.from(bottomSheet).setBottomSheetCallback(mBottomSheetCallback);
if (params == null) {
bottomSheet.addView(view);
} else {
bottomSheet.addView(view, params);
}
// We treat the CoordinatorLayout as outside the dialog though it is technically inside
if (shouldWindowCloseOnTouchOutside()) {
final View finalView = view;
coordinator.setOnTouchListener(new View.OnTouchListener() {
#Override
public boolean onTouch(View v, MotionEvent event) {
if (isShowing() &&
MotionEventCompat.getActionMasked(event) == MotionEvent.ACTION_UP &&
!coordinator.isPointInChildBounds(finalView,
(int) event.getX(), (int) event.getY())) {
cancel();
return true;
}
return false;
}
});
}
return coordinator;
}
No idea which one of those 2 behaviors you want but if you need the second one follow those steps:
Create a Java class and extend it from CoordinatorLayout.Behavior<V>
Copy paste code from the default BottomSheetBehavior file to your new one.
Modify the method clampViewPositionVertical with the following code:
#Override
public int clampViewPositionVertical(View child, int top, int dy) {
return constrain(top, mMinOffset, mHideable ? mParentHeight : mMaxOffset);
}
int constrain(int amount, int low, int high) {
return amount < low ? low : (amount > high ? high : amount);
}
Add a new state
public static final int STATE_ANCHOR_POINT = X;
Modify the next methods: onLayoutChild, onStopNestedScroll, BottomSheetBehavior<V> from(V view) and setState (optional)
And here is how it looks like
[]
Its works for me. Add code on BottomSheetDialogFragment's onViewCreated() methode
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val dialog = dialog as BottomSheetDialog
val bottomSheet = dialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout?
val behavior = BottomSheetBehavior.from(bottomSheet!!)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
val newHeight = activity?.window?.decorView?.measuredHeight
val viewGroupLayoutParams = bottomSheet.layoutParams
viewGroupLayoutParams.height = newHeight ?: 0
bottomSheet.layoutParams = viewGroupLayoutParams
}
})
dialogView = view
}
Don't forget to remove viewTreeObserver.
override fun onDestroyView() {
dialogView?.viewTreeObserver?.addOnGlobalLayoutListener(null)
super.onDestroyView()
}
Get reference to sheet behavior,
private val behavior by lazy { (dialog as BottomSheetDialog).behavior }
turn fitToContents off and set expandedOffset to desired pixels.
behavior.isFitToContents = false
behavior.expandedOffset = 100
Kotlin
In my case I need to define a fixed height and I did the following:
val bottomSheet: View? = dialog.findViewById(R.id.design_bottom_sheet)
BottomSheetBehavior.from(bottomSheet!!).peekHeight = 250
this way you also have access to any property of the BottomSheetBehavior such as halfExpandedRatio
I would advise against using ids to find views. In the BottomSheetDialogFragment the dialog is a BottomSheetDialog which exposes the behavior for the bottom sheet. You can use that to set the peek height.
(dialog as BottomSheetDialog).behavior.peekHeight = ...
I'm using the BottomSheetBehavior from Google recently released AppCompat v23.2. The height of my bottom sheet depends on the content displayed inside of the bottom sheet (similar to the what Google does themselves in their Maps app).
It works fine with the data loaded initially, but my application changes the content displayed during runtime and when this happens the bottom sheet retains at it's old height, which either leads to unused space at the bottom or a cut of view.
Is there any way to inform the bottom sheet layout to recalculate the height used for expanded state (when height of the ViewGroup is set to MATCH_HEIGHT) or any way to manually set the required height?
EDIT: I also tried to manually call invalidate() on the ViewGroup and the parent of it but without any success.
I had the same problem with RelativeLayout as my bottom sheet. The height won't be recalculated. I had to resort to setting the height by the new recalculated value and call BottomSheetBehavior.onLayoutChild.
This is my temporary solution:
coordinatorLayout = (CoordinatorLayout)findViewById(R.id.coordinator_layout);
bottomSheet = findViewById(R.id.bottom_sheet);
int accountHeight = accountTextView.getHeight();
accountTextView.setVisibility(View.GONE);
bottomSheet.getLayoutParams().height = bottomSheet.getHeight() - accountHeight;
bottomSheet.requestLayout();
behavior.onLayoutChild(coordinatorLayout, bottomSheet, ViewCompat.LAYOUT_DIRECTION_LTR);
You can use BottomSheetBehavior#setPeekHeight for that.
FrameLayout bottomSheet = (FrameLayout) findViewById(R.id.bottom_sheet);
BottomSheetBehavior<FrameLayout> behavior = BottomSheetBehavior.from(bottomSheet);
behavior.setPeekHeight(newHeight);
This does not automatically move the bottom sheet to the peek height. You can call BottomSheetBehavior#setState to adjust your bottom sheet to the new peek height.
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
Though the issue has been resolved in >=24.0.0 support library, if for some reason you still have to use the older version, here is a workaround.
mBottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
#Override
public void onStateChanged(#NonNull final View bottomSheet, int newState) {
bottomSheet.post(new Runnable() {
#Override
public void run() {
//workaround for the bottomsheet bug
bottomSheet.requestLayout();
bottomSheet.invalidate();
}
});
}
#Override
public void onSlide(#NonNull View bottomSheet, float slideOffset) {
}
});
For bottom sheet dialog fragment, read this: Bottom Sheet Dialog Fragment Expand Full Height
#Override
public void onActivityCreated(#Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
BottomSheetDialog dialog = (BottomSheetDialog) getDialog();
FrameLayout bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet);
BottomSheetBehavior behavior = BottomSheetBehavior.from(bottomSheet);
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
behavior.setPeekHeight(0);
}
I faced the same issue, when trying to update the peek height based on its contents, the height from a previous layout was found. This makes sense as the new layout had not taken place yet. By posting on the UI thread the layout height is calculated after the new layout, and another layout request is made to update the bottom sheet to the right height.
void show() {
setVisibility(View.VISIBLE);
post(new Runnable() {
#Override
public void run() {
mBottomSheetBehavior.setPeekHeight(findViewById(R.id.sheetPeek).getHeight());
requestLayout();
}
})
}
I was facing the same issue when I used a recyclerview inside a BottomSheet and the items changed dynamically. As #sosite has mentioned in his comment, the issue is logged and they have fixed it in the latest release.
Issue log here
Just update your design support library to version 24.0.0 and check.
I've followed #HaraldUnander advice, and it gave me an idea which has actually worked. If you run a thread (couldn't make it work with the post method as him) after the BottomSheetBehavior.state is set up programmatically to STATE_COLLAPSED, then you can already obtain the height of your views and set the peekHeight depending on it's content.
So first you set the BottomSheetBehavior:
BottomSheetBehavior.from(routeCaptionBottomSheet).state = BottomSheetBehavior.STATE_COLLAPSED
And then you set the peekHeight dynamically:
thread {
activity?.runOnUiThread {
val dynamicHeight = yourContainerView.height
BottomSheetBehavior.from(bottomSheetView).peekHeight = dynamicHeight
}
}
If using Java (I'm using Kotlin with Anko for threads), this could do:
new Thread(new Runnable() {
public void run() {
int dynamicHeight = yourContainerView.getHeight();
BottomSheetBehavior.from(bottomSheetView).setPeekHeight(dynamicHeight);
}
}).start();
Below code snippet helped me solve this issue where i am toggling between visibility of different views in layout and height is automatically changing for my bottom sheet.
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.your_bottom_sheet_layout, container, false)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
dialog.setContentView(R.layout.your_bottom_sheet_layout)
dialog.setOnShowListener {
val castDialog = it as BottomSheetDialog
val bottomSheet = castDialog.findViewById<View?>(R.id.design_bottom_sheet)
val behavior = BottomSheetBehavior.from(bottomSheet)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_DRAGGING) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
}
return dialog
}
I've been struggling with a problem similar to yours.
Manually setting the height of the bottomSheet was the solution for me.
Having a view viewA that has the BottomSheetBehaviour and a custom method modifyHeight() that modifies the height of the view:
viewA?.modifyHeight()
viewA?.measure(
MeasureSpec.makeMeasureSpec(
width,
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
0,
MeasureSpec.UNSPECIFIED
)
)
val layoutParams = LayoutParams(viewA.measuredWidth, viewA.measuredHeight)
val bottomSheet = BottomSheetBehavior.from(viewA)
layoutParams.behavior = bottomSheet
viewA.layoutParams = layoutParams
My layout would be something like:
<com.yourpackage.ViewA
android:id="#+id/viewA"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:behavior_peekHeight="50dp"
app:layout_behavior="#string/bottom_sheet_behavior" />
It is important to reuse the bottomSheetBehaviour of the old layoutParams because it contains the peekHeight and listeners you may have attached.
Here is the toggle button click listener I have implement to set pick height of bottom sheet with animation
FrameLayout standardBottomSheet = findViewById(R.id.standardBottomSheet);
BottomSheetBehavior<FrameLayout> bottomSheetBehavior = BottomSheetBehavior.from(standardBottomSheet);
btnToggleBottomSheet.setOnClickListener(new HPFM_OnSingleClickListener() {
#Override
public void onSingleClick(View v) {
if (bottomSheetBehavior.getPeekHeight() == 0) {
ObjectAnimator.ofInt(bottomSheetBehavior, "peekHeight", 200).setDuration(300).start();
}
else {
ObjectAnimator.ofInt(bottomSheetBehavior, "peekHeight", 0).setDuration(300).start();
}
}
});
It looks like CoordinatorLayout breaks the behaviour of Espresso actions such as scrollTo() or RecyclerViewActions.scrollToPosition().
Issue with NestedScrollView
For a layout like this one:
<android.support.design.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="#string/appbar_scrolling_view_behavior">
...
</android.support.v4.widget.NestedScrollView>
<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" >
...
</android.support.design.widget.AppBarLayout>
</android.support.design.widget.CoordinatorLayout>
If I try to scroll to any view inside the NestedScrollView using ViewActions.scrollTo() the first problem I find is that I get a PerformException. This is because this action only supports ScrollView and NestedScrollView doesn't extend it. A workaround for this problem is explained here, basically we can copy the code in scrollTo() and change the constrains to support NestedScrollView. This seems to work if the NestedScrollView is not in a CoordinatorLayout but as soon as you put it inside a the CoordinatorLayout the scrolling action fails.
Issue with RecyclerView
For the same layout, if I replace the NestedScrollView with a RecyclerView there is also problems with the the scrolling.
In this case I'm using RecyclerViewAction.scrollToPosition(position). Unlike the NestedScrollView, here I can see some scrolling happening. However, it looks like it scrolls to the wrong position. For example, if I scroll to the last position, it makes visible the second to last but not the last one. When I move the RecyclerView out of the CoordinatorLayout the scrolling works as it should.
At the moment we can't write any Espresso test for the screens that use CoordinatorLayout due to this issues. Anyone experiencing the same problems or knows a workaround?
This is happening because the Espresso scrollTo() method explicitly checks the layout class and only works for ScrollView & HorizontalScrollView. Internally it's using View.requestRectangleOnScreen(...) so I'd expect it to actually work fine for many layouts.
My workaround for NestedScrollView was to take ScrollToAction and modify that constraint. The modified action worked fine for NestedScrollView with that change.
Changed method in ScrollToAction class:
public Matcher<View> getConstraints() {
return allOf(withEffectiveVisibility(Visibility.VISIBLE), isDescendantOfA(anyOf(
isAssignableFrom(ScrollView.class), isAssignableFrom(HorizontalScrollView.class), isAssignableFrom(NestedScrollView.class))));
}
Convenience method:
public static ViewAction betterScrollTo() {
return ViewActions.actionWithAssertions(new NestedScrollToAction());
}
Here is how I did the same thing that #miszmaniac did in Kotlin.
With delegation in Kotlin, it is much cleaner and easier because I don't have to override the methods I don't need to.
class ScrollToAction(
private val original: android.support.test.espresso.action.ScrollToAction = android.support.test.espresso.action.ScrollToAction()
) : ViewAction by original {
override fun getConstraints(): Matcher<View> = anyOf(
allOf(
withEffectiveVisibility(Visibility.VISIBLE),
isDescendantOfA(isAssignableFrom(NestedScrollView::class.java))),
original.constraints
)
}
I had this issue with CoordinatorLayout->ViewPager->NestedScrollView an easy work around from me to get the same scrollTo() behavior was to just swipe up on the screen:
onView(withId(android.R.id.content)).perform(ViewActions.swipeUp());
The solution of Mr Mido may work in some situations, but not always. If you have some view in the bottom of screen, the scroll of your RecyclerView will not happen because the click will start outside the RecyclerView.
One way to workaround this problem is to write a custom SwipeAction. Like this:
1 - Create the CenterSwipeAction
public class CenterSwipeAction implements ViewAction {
private final Swiper swiper;
private final CoordinatesProvider startCoordProvide;
private final CoordinatesProvider endCoordProvide;
private final PrecisionDescriber precDesc;
public CenterSwipeAction(Swiper swiper, CoordinatesProvider startCoordProvide,
CoordinatesProvider endCoordProvide, PrecisionDescriber precDesc) {
this.swiper = swiper;
this.startCoordProvide = startCoordProvide;
this.endCoordProvide = endCoordProvide;
this.precDesc = precDesc;
}
#Override public Matcher<View> getConstraints() {
return withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE);
}
#Override public String getDescription() {
return "swipe from middle of screen";
}
#Override
public void perform(UiController uiController, View view) {
float[] startCoord = startCoordProvide.calculateCoordinates(view);
float[] finalCoord = endCoordProvide.calculateCoordinates(view);
float[] precision = precDesc.describePrecision();
// you could try this for several times until Swiper.Status is achieved or try count is reached
try {
swiper.sendSwipe(uiController, startCoord, finalCoord, precision);
} catch (RuntimeException re) {
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(re)
.build();
}
// ensures that the swipe has been run.
uiController.loopMainThreadForAtLeast(ViewConfiguration.getPressedStateDuration());
}
}
2 - Create the method to return the ViewAction
private static ViewAction swipeFromCenterToTop() {
return new CenterSwipeAction(Swipe.FAST,
GeneralLocation.CENTER,
view -> {
float[] coordinates = GeneralLocation.CENTER.calculateCoordinates(view);
coordinates[1] = 0;
return coordinates;
},
Press.FINGER);
}
3 - Then use it to scroll the screen:
onView(withId(android.R.id.content)).perform(swipeFromCenterToTop());
And that's it! This way you can control how the scroll is going to happen in your screen.
Barista's scrollTo(R.id.button) works on all kinds of scrollable views, also on NestedScrollView.
It's useful to fix this kind of issues with Espresso. We develop and use it just to write Espresso tests in a fast and reliable way. And here's a link: https://github.com/SchibstedSpain/Barista
This issue has been reported (perhaps by the OP?), see Issue 203684
One of the comments to that issue suggests a work-around to the problem when the NestedScrollView is inside of a CoordinatorLayout:
you need to remove the #string/appbar_scrolling_view_behavior layout behaviour of the ScrollingView or any parent view this ScrollingView is included in
Here is an implementation of that work-around:
activity.runOnUiThread(new Runnable() {
#Override
public void run() {
// remove CoordinatorLayout.LayoutParams from NestedScrollView
NestedScrollView nestedScrollView = (NestedScrollView)activity.findViewById(scrollViewId);
CoordinatorLayout.LayoutParams params =
(CoordinatorLayout.LayoutParams)nestedScrollView.getLayoutParams();
params.setBehavior(null);
nestedScrollView.requestLayout();
}
});
I was able to get my tests working by:
Making a custom scrollTo() action (as referenced by the OP and Turnsole)
Removing the NestedScrollView's layout params as shown here
I've made a NestedScrollViewScrollToAction class.
I think it's better place to make activity specific stuff there instead.
The only thing worth mentioning is that code searches for parent nestedScrollView and removes it's CoordinatorLayout behaviour.
https://gist.github.com/miszmaniac/12f720b7e898ece55d2464fe645e1f36
I had to test recyclerview items. My RecyclerView was in NestedScrollView inside an CoordinatorLayout.
Following is the solution worked for me, I feel it is the most suitable solution to test RecyclerView items in an NestedScrollView.
Step 1 : Copy and paste the below function
Following will return the desired child view from recyclerView which we are about to test.
fun atPositionOnView(recyclerViewId: Int, position: Int, childViewIdToTest: Int): Matcher<View?>? {
return object : TypeSafeMatcher<View?>() {
var resources: Resources? = null
var childView: View? = null
override fun describeTo(description: Description?) {
var idDescription = Integer.toString(recyclerViewId)
if (resources != null) {
idDescription = try {
resources!!.getResourceName(recyclerViewId)
} catch (var4: Resources.NotFoundException) {
String.format("%s (resource name not found)",
*arrayOf<Any?>(Integer.valueOf(recyclerViewId)))
}
}
description?.appendText("with id: $idDescription")
}
override fun matchesSafely(view: View?): Boolean {
resources = view?.getResources()
if (childView == null) {
val recyclerView = view?.getRootView()?.findViewById(recyclerViewId) as RecyclerView
childView = if (recyclerView != null && recyclerView.id == recyclerViewId) {
recyclerView.findViewHolderForAdapterPosition(position)!!.itemView
} else {
return false
}
}
return if (viewId == -1) {
view === childView
} else {
val targetView = childView!!.findViewById<View>(viewId)
view === targetView
}
}
}
}
Step 2: Now copy and paste below function
Following will check if your child in recyclerView is being displayed or not.
fun ViewInteraction.isNotDisplayed(): Boolean {
return try {
check(matches(not(isDisplayed())))
true
} catch (e: Error) {
false
}
}
Step 3: Test your recyclerView Items and scroll If they are off screen
Following will scroll the non displayed child and make it appear on screen.
if (onView(atPositionOnView(R.id.rv_items, pos, R.id.tv_item_name)).isNotDisplayed()) {
val appViews = UiScrollable(UiSelector().scrollable(true))
appViews.scrollForward() //appViews.scrollBackward()
}
Once your view is being displayed, you can perform your test cases.