MaterialContainerTransform transition is not Working on Return
Asked Answered
B

2

8

My MaterialContainerTransform transition is working from source -> destination, but not the other way around. My situation is pretty standard -- I am attempting to provide a simple transition from a RecyclerView item (source Fragment) to a "details" Fragment (destination Fragment). The items within the RecyclerView are MaterialCardViews, each with an ImageView which is shared in the destination Fragment.

Following these docs https://material.io/develop/android/theming/motion it seems to be fairly straightforward, though the docs are written in Kotlin and I am using Java so maybe I am missing something? I dynamically set the ImageView transitionName in each RecyclerView item, and pass that to the destination Fragment which then copies the transitionName to its own ImageView. Through logging, I can confirm the shared transitionName matches in each fragment.

The transition from source -> destination works great, but when I hit the back button there is no transition back. This is strange because even the docs state:

Fragments are able to define enter and return shared element transitions. When only an enter shared element transition is set, it will be re-used when the Fragment is popped (returns).

Any ideas why the return transition (destination -> source) is not functioning? Here is the relevant code:

RecyclerView Item (Source) **the ImageView is the shared element

<com.google.android.material.card.MaterialCardView
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/result_layout_card"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="5dp"
    app:cardElevation="4dp"
    android:layout_margin="8dp"
    android:clickable="true"
    android:focusable="true"
    android:checkable="true"
    app:checkedIconTint="@color/checkedYellow"
    >

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:layout_gravity="center">


        <ImageView
            android:id="@+id/result_image_thumbnail"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:adjustViewBounds="true"
            android:scaleType="fitCenter"
            android:contentDescription="@string/thumbnail_content_description"
            android:src="@drawable/corolla_preview"
            />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="5dp"
            android:orientation="vertical">

            <TextView
                android:id="@+id/result_text_title"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="2018 Toyota Corolla"
                style="@style/TextAppearance.MaterialComponents.Subtitle1"
                />

            <TextView
                android:id="@+id/result_text_stock"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="#626546"
                style="@style/TextAppearance.MaterialComponents.Body2"
                />

            <TextView
                android:id="@+id/result_text_price"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="$28,998"
                style="@style/TextAppearance.MaterialComponents.Overline"
                />

        </LinearLayout>

    </LinearLayout>

</com.google.android.material.card.MaterialCardView>

Detail Fragment (destination) ** again the ImageView is the shared element

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    >

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="8dp">

        <ImageView
            android:id="@+id/detail_image_thumbnail"
            android:layout_width="match_parent"
            android:layout_height="225dp"
            android:contentDescription="@string/thumbnail_content_description"/>
        
    </LinearLayout>
</ScrollView>

RecyclerView Holder (source) ** the viewResults method is where I start the fragment transaction

public class ResultsHolder extends RecyclerView.ViewHolder {

    private static final String TAG = ResultsHolder.class.getSimpleName();
    private final FragmentManager fragmentManager;
    private ResultModel resultModel;

    private final MaterialCardView cardView;
    private final ImageView thumbnail;
    private String searchId;

    private ResultsFragment resultsFragment;
    private int position;
    private View thumbnailView;

    public ResultsHolder(View itemView, FragmentManager fragmentManager,
                         String searchId, ResultsFragment resultsFragment) {
        super(itemView);

        cardView = itemView.findViewById(R.id.result_layout_card);
        thumbnail = itemView.findViewById(R.id.result_image_thumbnail);

        this.fragmentManager = fragmentManager;
        this.searchId = searchId;

        this.resultsFragment = resultsFragment;

    }

    public void bindResult(ResultModel result, int position) {
        resultModel = result;

        Picasso.get().load(result.getImageUrl()).into(thumbnail);

        cardView.setChecked(result.isChecked());
        cardView.setOnClickListener(cardViewClickListener);

        this.position = position;
        thumbnail.setTransitionName(result.getVin()); // transition name is unique for each recyclerview 
                                                      // item

    }

    private final View.OnClickListener cardViewClickListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            viewDetails(resultModel, searchId);
        }
    };


    // This is the method where I start the destination fragment
    public void viewDetails(ResultModel result, String searchId) {
        Bundle args = new Bundle();
        args.putParcelable("RESULT", Parcels.wrap(result));
        args.putString("SEARCH_ID", searchId);

        DetailFragment fragment = new DetailFragment();

        // Destination fragment enter transition!
        fragment.setSharedElementEnterTransition(new MaterialContainerTransform());
        fragment.setArguments(args);

        fragmentManager
                .beginTransaction()
                .setReorderingAllowed(true)
                .addSharedElement(thumbnail, thumbnail.getTransitionName()) // Shared element!
                .replace(R.id.main_fragment_container,
                        fragment,
                        DetailFragment.class.getSimpleName())
                .addToBackStack(null)
                .commit();
    }

Details Fragment (destination)

public class DetailFragment extends Fragment {

    @SuppressWarnings("unused")
    private static final String TAG = "DetailFragment";
    private ResultModel result;
    private String searchId;

    ImageView image;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (getArguments() != null) {

            // Get result and search ID
            result = Parcels.unwrap(getArguments().getParcelable("RESULT"));
            searchId = getArguments().getString("SEARCH_ID");

        }

    }


    private void instantiateUI(View v) {
        
        TextView vin = v.findViewById(R.id.tv_details_vin);
        vin.setText(result.getVin());
        
        image = v.findViewById(R.id.detail_image_thumbnail);
        Picasso.get().load(result.getImageUrl()).fit().into(image);

    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        // Inflate the layout for this fragment
        View v = inflater.inflate(R.layout.fragment_detail, container, false);

        // Set transitionName exactly same as the recyclerview item which was clicked
        v.findViewById(R.id.detail_image_thumbnail).setTransitionName(result.getVin());
        return v;
    }

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        if (((AppCompatActivity) getActivity()) != null
                && ((AppCompatActivity) getActivity()).getSupportActionBar() != null) {

            Objects.requireNonNull(((AppCompatActivity) getActivity())
                    .getSupportActionBar()).setTitle("Details");
        }

        instantiateUI(view);
    }
}
Backstitch answered 13/1, 2021 at 18:3 Comment(2)
Did you get the answer. Looking forward to your answer I raised a similar issue today: issuetracker.google.com/issues/359296631 Please ignore the version number. I will update the issue postClarineclarinet
Working fine after material library update: mvnrepository.com/artifact/com.google.android.material/material/…Clarineclarinet
B
8

I have implemented this in one on my old projects. Check what are you missing from the below:

1.Add the below lines of code in your SourceFragment. The Key points here is in onViewCreated() method you have to use the postponeEnterTransition() and startPostponedEnterTransition() which is required to animate correctly when the user returns to the source fragment. Also in onCreate() method set an Exit Transition and a ReenterTransition to have the list of items scale out when exiting and back in when reentering:

public class SourceFragment extends Fragment {

    private RecyclerView recyclerView;
    private LinearLayoutManager mLayoutManager;

    public static int index = -1;
    public static int top = -1;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //set the below transitions so as the source list scale out when exiting and back in when reentering
        setExitTransition(new MaterialElevationScale(false));
        setReenterTransition(new MaterialElevationScale(true));
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_source, container, false);
        recyclerView = view.findViewById(R.id.recycler_view);
        mLayoutManager = new LinearLayoutManager(getContext());
        recyclerView.setLayoutManager(mLayoutManager);
        recyclerView.setAdapter(new SourceAdapter(this, getFragmentManager()));
        return view;
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        //the below code is required to animate correctly when the user returns to the source fragment
        //gives a chance for the layout to be fully laid out before animating it
        postponeEnterTransition();
        ((ViewGroup) view.getParent()).getViewTreeObserver()
                .addOnPreDrawListener(() -> {
                    startPostponedEnterTransition();
                    return true;
                });
    }

    @Override
    public void onPause() {
        super.onPause();
        //Save the current state of recycle view position
        index = mLayoutManager.findFirstVisibleItemPosition();
        View startView = recyclerView.getChildAt(0);
        top = (startView == null) ? 0 : (startView.getTop() - recyclerView.getPaddingTop());
    }

    @Override
    public void onResume()
    {
        super.onResume();
        //Scrolls the recycler view to the clicked item position when navigating back
        if(index != -1) {
            mLayoutManager.scrollToPositionWithOffset(index, top);
        }
    }
}

2.Set a transition name to map the shared element eg: the Image Item (Start View) to its DestinationFragment Image (End View). In the SourceAdapter in onBindViewHolder() set a unique identifier for each image item. According to your code will be:

thumbnail.setTransitionName(result.getVin());

3.Pass this unique identifier into your DestinationFragment (DetailFragment) and finalize the mapping in onCreateView(). According to your code will be:

@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        // Inflate the layout for this fragment
        View v = inflater.inflate(R.layout.fragment_detail, container, false);

        // Set transitionName exactly same as the recyclerview item which was clicked
        v.findViewById(R.id.detail_image_thumbnail).setTransitionName(result.getVin());
        return v;
}

4.Add the enter Transition in onCreate() method of your DestinationFragment (DetailFragment) like below:

@Override
public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //prepare the EnterTransition
        MaterialContainerTransform transform = new MaterialContainerTransform();
        transform.setScrimColor(Color.TRANSPARENT);
        setSharedElementEnterTransition(transform);
        //get your arguments according to your code
        if (getArguments() != null) {
            result = Parcels.unwrap(getArguments().getParcelable("RESULT"));
            searchId = getArguments().getString("SEARCH_ID");
        }
    }

5.Finally switch from SourceFragment to DestinationFragment according to your code:

        Bundle args = new Bundle();
        args.putParcelable("RESULT", Parcels.wrap(result));
        args.putString("SEARCH_ID", searchId);

        DetailFragment fragment = new DetailFragment();
        fragment.setArguments(args);

        fragmentManager
                .beginTransaction()
                .setReorderingAllowed(true)
                .addSharedElement(thumbnail, thumbnail.getTransitionName()) // Shared element!
                .replace(R.id.main_fragment_container,
                        fragment,
                        DetailFragment.class.getSimpleName())
                .addToBackStack(null)
                .commit();
Breath answered 19/1, 2021 at 10:15 Comment(2)
Thank you for your answer. I tried what you suggested and it did not work with my code. I still awarded your answer the 50 point bounty though because yours had the most effort put into it. Thank you for that. I'm going to try to replicate my problem with a simpler codebase (e.g. without the Picasso loaded pictures, simple one child views, etc.) and go from there.Backstitch
Thanks a lot @rusty1996 for the bounty. All the above steps work perfect on my project and both enter and return transitions work as expected. Another thing you can check is the result.getVin() if is really a unique identifier and if is passed correctly to the DestinationFragment. Replicating the issue on a simpler codebase is a perfect idea without Picasso just use images from your drawable folder to check what might be wrong. If you need more help don't hesitate to upload a demo project on github so i can clone it and investigating further your issue what else might be wrong. Thanks again.Breath
D
1

I have not worked through your code, but the following may address your issue. From the Material Motion Codelab. The "collapse" mentioned is the return from a detail view to a RecyclerView item.

Typically, this first issue of the collapse not working is because when the Android Transition system is trying to run your return transition, the list of emails hasn't been inflated and populated into the RecyclerView yet. We need a way to wait until our HomeFragment lays out our list before we start our transitions.

The Android Transition system provides methods to do just that - postponeEnterTransition and startPostponedEnterTransition. If postponeEnterTransition is called, any entering transition to be run will be held until a closing call to startPostponedEnterTransition is called. This gives us the opportunity to "schedule" our transitions until after the RecyclerView has been populated with emails and the transition is able to find the mappings you configured.

Daimon answered 17/1, 2021 at 1:12 Comment(2)
thank you for the attempt at an answer! I tried as you suggested -- In my ResultsFragment, which is the parent of the RecyclerView Holder (the source), I called postponeEnterTransition() in the fragment's onResume() method, and called startPostponedEnterTransition() right after my call to adapter.setResults(results) which populates the recyclerview. This still did not change anything though! Would you like me to show more of my code?Backstitch
@rusty1996 I think that MariosP's answer may have captured the gist of what I was saying. Check it out.Daimon

© 2022 - 2024 — McMap. All rights reserved.