简体   繁体   中英

Recyclerview reverse layout starting scroll position issue

I'm creating a chat app and for displaying messages I'm using recycler view. Newest messages are displayed on the bottom. The user scrolls up to see more messages.

When the chat screen is loaded the view doesn't start from the very bottom, and the newest message isn't visible. The user has to scroll down a few rows to see the newest message. This is bad UI and the newest message should be visible at the end/bottom of the screen.

I'm using setReverseLayout(true) and setStackFromEnd(false) , and I've searched online for similar issues with no luck. For now, I'm setting the scroll position to 0 right after setting up the recycler view with a delay, but this doesn't always work and it's jumpy.

If I set up recyclerview normally(without using setReverseLayout and setStackFromEnd ), the newest message loads at the top each time perfectly as it should.

Here's the code to start recycler view:

RecyclerView recyclerView = findViewById(R.id.recyclerViewMessageRoom);
    adapter = new RA_MessageRoom(this, userFirebaseUid, messagesGroupedByDate, messageUsers);
    recyclerView.setAdapter(adapter);

    LinearLayoutManager layoutManager = new LinearLayoutManager(this);
    layoutManager.setReverseLayout(true);
    layoutManager.setStackFromEnd(false);
    recyclerView.setHasFixedSize(true);
    recyclerView.setLayoutManager(layoutManager);

    // -- Workaround with delay - still doesn't completely work
    new Handler().postDelayed(() -> {
        recyclerView.scrollToPosition(0);
    }, 200);

Anyone who's experienced this issue and knows how to resolve it please share. Thanks.

EDIT (6 JUL 2019):

Here is the recycler adapter's code. As a reminder if I remove the reverse layout settings it works perfectly fine.

RA_MessageRoom:

public class RA_MessageRoom extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final String TAG = "RA_MessageRoom";
private static final int TYPE_USER = 1;
private static final int TYPE_PARTICIPANT = 2;

private Context context;
private String userFirebaseUid;
private List<MessagesDateGrouper> messagesGroupedByDate;
private HashSet<MessagesUserModel> messageUsers;

public RA_MessageRoom(Context context, String userFirebaseUid, List<MessagesDateGrouper> messagesGroupedByDate, HashSet<MessagesUserModel> messageUsers) {
    this.context = context;
    this.userFirebaseUid = userFirebaseUid;
    this.messagesGroupedByDate = messagesGroupedByDate;
    this.messageUsers = messageUsers;
}

@Override
public int getItemViewType(int position) {

    if (messagesGroupedByDate.get(position).getViewType() == MessagesDateGrouper.TYPE_CHAT) {
        MessageChatItem message = (MessageChatItem) messagesGroupedByDate.get(position);
        if (message.getMessages().getSenderFirebaseUid().equals(userFirebaseUid)) {
            return TYPE_USER;
        } else {
            return TYPE_PARTICIPANT;
        }
    } else {
        return messagesGroupedByDate.get(position).getViewType();
    }
}


@Override
public int getItemCount() {
    return messagesGroupedByDate != null ? messagesGroupedByDate.size() : 0;
}

@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
    final RecyclerView.ViewHolder holder;
    View view;

    switch (viewType) {
        case MessagesDateGrouper.TYPE_DATE:
            view = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_message_room_separator, parent, false);
            holder = new MessageRoomDateVH(view);
            break;

        case TYPE_USER:
            view = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_message_room_user, parent, false);
            holder = new MessageRoomUserVH(view);
            break;

        case TYPE_PARTICIPANT:
            view = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_message_room_participant, parent, false);;
            holder = new MessageRoomParticipantVH(view);
            break;

        default:
            view = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_message_room_user, parent, false);;
            holder = new MessageRoomUserVH(view);
            break;
    }

    return holder;
}

@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
    if (holder instanceof MessageRoomDateVH) {
        MessageDateItem date = (MessageDateItem) messagesGroupedByDate.get(position);
        ((MessageRoomDateVH)holder).date.setText(date.getDate());

    } else if (holder instanceof MessageRoomParticipantVH) {
        MessageRoomParticipantVH view = (MessageRoomParticipantVH) holder;
        MessageChatItem messageItem = (MessageChatItem) messagesGroupedByDate.get(position);
        MessagesModel message = messageItem.getMessages();
        
        view.name.setText(context.getString(R.string.unknown));
        view.profileImage.setImageResource(R.drawable.default_profile_image_grey);

        for (MessagesUserModel user: messageUsers) {
            if (user.getFirebaseId().equals(message.getSenderFirebaseUid())) {
                int fallbackImage;
                if (user.getMerchant() == null || !user.getMerchant()) {
                    fallbackImage = R.drawable.default_profile_image_grey;
                } else {
                    fallbackImage = R.drawable.store_profile;
                }

                GlideApp.with(context)
                        .load(user.getPhotoThumbUrl())
                        .placeholder(R.drawable.placeholder)
                        .fallback(fallbackImage)
                        .into(view.profileImage);

                view.name.setText(user.getName());
                break;
            }
        }


        if (message.getImageUrl() != null && !message.getImageUrl().equals("") ) {
            view.image.setVisibility(View.VISIBLE);
            view.imageSpinner.setVisibility(View.VISIBLE);
            view.chat.setVisibility(View.GONE);



            view.image.setClipToOutline(true);

            GlideApp.with(context)
                    .load(message.getImageUrl())
                    .placeholder(R.drawable.placeholder_message)
                    .fallback(R.drawable.placeholder_message)
                    .listener(new RequestListener<Drawable>() {
                        @Override
                        public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
                            return false;
                        }

                        @Override
                        public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
                            view.imageSpinner.setVisibility(View.GONE);
                            return false;
                        }
                    })
                    .into(view.image);


        } else {
            view.chat.setVisibility(View.VISIBLE);
            view.image.setVisibility(View.GONE);
            view.imageSpinner.setVisibility(View.GONE);

            view.chat.setText(message.getMessageText());
        }

        String time = DateFormatService.messageRoomParseDateToTimeString(message.getDate());
        view.date.setText(time);

    } else if (holder instanceof MessageRoomUserVH){
        MessageRoomUserVH view = (MessageRoomUserVH) holder;
        MessageChatItem messageItem = (MessageChatItem) messagesGroupedByDate.get(position);
        MessagesModel message = messageItem.getMessages();


        if (message.getImageUrl() != null && !message.getImageUrl().equals("") ) {
            view.image.setVisibility(View.VISIBLE);
            view.imageSpinner.setVisibility(View.VISIBLE);
            view.chat.setVisibility(View.GONE);

            view.image.setClipToOutline(true);

            GlideApp.with(context)
                    .load(message.getImageUrl())
                    .placeholder(R.drawable.placeholder_message)
                    .fallback(R.drawable.placeholder_message)
                    .listener(new RequestListener<Drawable>() {
                        @Override
                        public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
                            return false;
                        }

                        @Override
                        public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
                            view.imageSpinner.setVisibility(View.GONE);
                            return false;
                        }
                    })
                    .into(view.image);


        } else {
            view.chat.setVisibility(View.VISIBLE);
            view.image.setVisibility(View.GONE);
            view.imageSpinner.setVisibility(View.GONE);

            view.chat.setText(message.getMessageText());
        }

        String time = DateFormatService.messageRoomParseDateToTimeString(message.getDate());
        view.date.setText(time);
    }


}

public class MessageRoomDateVH extends RecyclerView.ViewHolder {
    TextView date;

    public MessageRoomDateVH(@NonNull View itemView) {
        super(itemView);
        date = itemView.findViewById(R.id.textMessageRoomDateSection);
    }
}


public class MessageRoomParticipantVH extends RecyclerView.ViewHolder {
    ImageView profileImage;
    TextView name;
    TextView chat;
    ImageView image;
    TextView date;
    ProgressBar imageSpinner;

    public MessageRoomParticipantVH(@NonNull View itemView) {
        super(itemView);

        profileImage = itemView.findViewById(R.id.imageMessageRoomParticipantProfile);
        name = itemView.findViewById(R.id.textMessageRoomParticipantName);
        chat = itemView.findViewById(R.id.textMessageRoomParticipantChat);
        image = itemView.findViewById(R.id.imageMessageRoomParticipantImage);
        date = itemView.findViewById(R.id.textMessageRoomParticipantDate);
        imageSpinner = itemView.findViewById(R.id.progressBarMessageRoomParticipantImage);


    }
}

public class MessageRoomUserVH extends RecyclerView.ViewHolder {
    TextView chat;
    ImageView image;
    TextView date;
    ProgressBar imageSpinner;

    public MessageRoomUserVH(@NonNull View itemView) {
        super(itemView);

        chat = itemView.findViewById(R.id.textMessageRoomUserChat);
        image = itemView.findViewById(R.id.imageMessageRoomUserImage);
        date = itemView.findViewById(R.id.textMessageRoomUserDate);
        imageSpinner = itemView.findViewById(R.id.progressBarMessageRoomUserImage);
    }
}

}

EDIT:

Although the preference was to have stackFromEnd set to false, I ended up changing stackFromEnd from false to true, removed the delay, and kept setting the scroll position as suggested by the accepted answer to resolve this issue.

Updated Working Code:

RecyclerView recyclerView = findViewById(R.id.recyclerViewMessageRoom);
    adapter = new RA_MessageRoom(this, userFirebaseUid, messagesGroupedByDate, messageUsers);
    recyclerView.setAdapter(adapter);

    LinearLayoutManager layoutManager = new LinearLayoutManager(this);
    layoutManager.setReverseLayout(true);
    layoutManager.setStackFromEnd(true);
    recyclerView.setHasFixedSize(true);
    recyclerView.setLayoutManager(layoutManager);
    recyclerView.scrollToPosition(0);

Thanks for the help!

You will also need to handle message loads on first page load and new messages received to be at bottom of recyclerview :

if (pageLoad) { 
 
 list.add(Model)

} else {
 
 // Add to the top of the list (since list is reverse message will come at the bottom)
  list.add(0, Model)

}

this is very easy you have to add only single line :-

LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
linearLayoutManager.setStackFromEnd(true);
recyclerView.setLayoutManager(linearLayoutManager);

See this answer:

RecyclerView - Reverse Order

And create a setter to your RA_MessageRoom to update your messagesGroupedByDate . Something like that:

Collections.reverse(messagesGroupedByDate); // Reverse your dataset like in answer above
adapter.setMessagesGroupedByDate(messagesGroupedByDate); // Update your dataset in adapter
adapter.notifyDataSetChanged(); // Notify your adapter

With this every time new messages arrive your list will be updated. You need to put this snippet in your data fetch and then you can remove your postDelayed handler.

You can set layout manager in xml and reverse layout. When we set reverse layout we find this issue. We can resolve it by adding setStackFromEnd =true

    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
    android:orientation="vertical"
    app:reverseLayout="true"
    app:stackFromEnd="true"

Note: The accepted answer misleads you.

There are 4 possibilities regarding LinearLayoutManager 's listing passion.

    1. 
     startStackFromEnd=true
     reverseLayout=true
    2.
     startStackFromEnd=false
     reverseLayout=false
    3. 
     startStackFromEnd=true
     reverseLayout=false
    4. 
     startStackFromEnd=false //best for chatting
     reverseLayout=true      //applications

each combination acts differently i don't know what exactly is your requirements, so play around these values, and i'm sure you'll get what you want.

Looks like a bug in recycler view implementation or rather in that linear layout manager.

To workaround it you need to make sure stackFromEnd is true when your recycler can scroll. If your recycler does not have enough item to scroll and you still want them to be at the bottom of your screen then set back stackFromEnd to false if recycler can't scroll.

To tell if your view can scroll you can use that Kotlin extension:

/**
 * Tells if this view can scroll vertically.
 * This view may still contain children who can scroll.
 */
fun View.canScrollVertically() = this.let {
    it.canScrollVertically(-1) || it.canScrollVertically(1)
}

In Fulguris I used the following function to fix that bug, the trick being to find the right place to call it in your code:

/**
 * Workaround reversed layout bug: https://github.com/Slion/Fulguris/issues/212    
 */
fun fixScrollBug(aList : RecyclerView): Boolean {
    val lm = (aList.layoutManager as LinearLayoutManager)
    // Can't change stackFromEnd when computing layout or scrolling otherwise it throws an exception
    if (!aList.isComputingLayout) {
        if (aList.context.configPrefs.toolbarsBottom) {
            // Workaround reversed layout bug: https://github.com/Slion/Fulguris/issues/212
            if (lm.stackFromEnd != aList.canScrollVertically()) {
                lm.stackFromEnd = !lm.stackFromEnd
                return true
            }
        } else {
            // Make sure this is set properly when not using bottom toolbars
            // No need to check if the value is already set properly as this is already done internally
            lm.stackFromEnd = false
        }
    }

    return false
}

You could replace that application specific check on toolbarsBottom with one against lm.reverseLayout I guess.

Had the very same issue - submitting a list would always result in items shown are somewhere from the middle of the list instead of the first item being shown at the bottom. This happens when app:reverseLayout="true" but works regularly for non-reversed . Realized that my recyclerview was having 0dp height inside a constraintlayout, with constraints top to parent and bottom to parent. Changed to match_parent for height and it works properly - now the first item on the bottom is the starting point when list is submitted.

if you want the same reliability (behaviour) as when the Recycler view is used by default ie with startStackFromEnd=false and reverseLayout=false the best solution I found is to apply a 180 rotation to the recycler view and the same to the adapter item.

so in your fragment class you'll have something like

override fun onCreateView(inflater: LayoutInflater,
                              container: ViewGroup?,
                              savedInstanceState: Bundle?): View {

     //code...
     yourRecyclerView.rotation = 180F
     //code...

}

and in your adapter (with view or dataBinding) something like

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {

    //code...
    val binding = //inflation...
    binding.root.rotation = 180F
    //code

}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM