1

I have a recyclerview showing items like this:

screenshot of fragment

Selecting items calculates a total price from the number you see in the circle of each item, adding them up while you select more items. The upper right corner shows how mach money is left.

Now I want to grey out items if the money left is smaller of the price for an item, which can be seen in the first item "Enlightenment". I have this achieved by setting the alpha inside the OnBindViewHolder to 0.5F instead of 1.0f.

My problem is, that this works only for items currently not seen, aka outside of the screen. In the example of my screenshot I scrolled down first until Enlightenment was out of sight and then scrolled it back in to view achieving what you can see. But I want to visible items to also reflect if they are buyable or not the moment I select another item. For instance in the screenshot, everything which is currently shown but the two already selected items should be greyed out, as there is only 20 left which is not enough the items you see.

How can I achieve this for the items visible?

    public class CivicsListAdapter extends ListAdapter<Card, CivicsViewHolder> {
    
    private SelectionTracker<String> tracker;
    private CivicViewModel mCivicViewModel;
    private LinearLayoutManager mLayout;
    
    public CivicsListAdapter(@NonNull DiffUtil.ItemCallback<Card> diffCallback, LinearLayoutManager layout) {
        super(diffCallback);
        this.mLayout = layout;
    }
    
    @NonNull
    @Override
    public CivicsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return CivicsViewHolder.create(parent);
    }
    
    @Override
    public void onBindViewHolder(@NonNull CivicsViewHolder holder, int position) {
        Card current = getItem(position);
        String name = current.getName();
        int price = current.getCurrentPrice();
        Resources res = holder.itemView.getResources();
        boolean isSelected = tracker.isSelected(name);
    
        holder.bindName(name, getItemBackgroundColor(current, res));
        holder.bindPrice(current.getCurrentPrice());
        holder.bindBonus(current.getBonus());
        holder.bindBonusCard(current.getBonusCard());
    
    
        holder.bindIsActive(isSelected);
        holder.itemView.setOnClickListener(v -> {
            // clicked on single card in list
            tracker.select(name);
            Toast.makeText(v.getContext(), name
                    + " clicked. \nYou can select more advances if you have the treasure.",Toast.LENGTH_SHORT).show();
        });
        if (!isSelected && price == 0) {
            tracker.select(name);
        } else {
            // can we buy the card?
            if (!isSelected && mCivicViewModel.getRemaining().getValue() < price) {
               holder.mCardView.setBackgroundResource(R.color.dark_grey);
                holder.mCardView.setAlpha(0.5F);
            } else {
              holder.mCardView.setBackgroundResource(R.drawable.item_background);
                holder.mCardView.setAlpha(1F);
            }
        }
    
        if (current.getBonus() > 0) {
            holder.mFamilyBox.setVisibility(View.VISIBLE);
        }
        else {
            holder.mFamilyBox.setVisibility(View.INVISIBLE);
        }
    }
    
        static class CivicsDiff extends DiffUtil.ItemCallback<Card> {
    
            @Override
            public boolean areItemsTheSame(@NonNull Card oldItem, @NonNull Card newItem) {
                Log.v("DIFF", "inside are ItemsTheSame");
                return oldItem.getIsBuyable() == newItem.getIsBuyable();
            }
    
            @Override
            public boolean areContentsTheSame(@NonNull Card oldItem, @NonNull Card newItem) {
                Log.v("DIFF", "inside are ContentTheSame");
                return oldItem.getIsBuyable() == oldItem.getIsBuyable();
            }
        }
        public void setSelectionTracker(SelectionTracker<String> tracker) {
            this.tracker = tracker;
        }
        public void setCivicViewModel(CivicViewModel model) {this.mCivicViewModel = model;}
    
        public static Drawable getItemBackgroundColor(Card card, Resources res) {
            int backgroundColor = 0;
            if (card.getGroup2() == null) {
                switch (card.getGroup1()) {
                    case ORANGE:
                        backgroundColor = R.color.crafts;
                        break;
                    case YELLOW:
                        backgroundColor = R.color.religion;
                        break;
                    case RED:
                        backgroundColor = R.color.civic;
                        break;
                    case GREEN:
                        backgroundColor = R.color.science;
                        break;
                    case BLUE:
                        backgroundColor = R.color.arts;
                        break;
                    default:
                        backgroundColor = R.color.purple_700;
                        break;
                }
            } else {
                switch (card.getName()) {
                    case "Engineering":
                        backgroundColor = R.drawable.engineering_background;
                    break;
                    case "Mathematics":
                        backgroundColor = R.drawable.mathematics_background;
                    break;
                    case "Mysticism":
                        backgroundColor = R.drawable.mysticism_background;
                    break;
                    case "Written Record":
                        backgroundColor = R.drawable.written_record_background;
                    break;
                    case "Theocracy":
                        backgroundColor = R.drawable.theocracy_background;
                    break;
                    case "Literacy":
                        backgroundColor = R.drawable.literacy_background;
                    break;
                    case "Wonder of the World":
                        backgroundColor = R.drawable.wonders_of_the_world_background;
                    break;
                    case "Philosophy":
                        backgroundColor = R.drawable.philosophy_background;
                    break;
                }
            }
            return ResourcesCompat.getDrawable(res,backgroundColor, null);
        }
    }
    
    
    
    
    
        class CivicsViewHolder extends RecyclerView.ViewHolder {
        
            private final TextView nameItemView;
            private final TextView priceItemView;
            private final TextView bonusCardItemView;
            private final TextView bonusItemView;
            public final View mCardView;
            public final LinearLayout mFamilyBox;
        
            private CivicsViewHolder(View itemView) {
                super(itemView);
                nameItemView = itemView.findViewById(R.id.name);
                priceItemView = itemView.findViewById(R.id.price);
                bonusCardItemView = itemView.findViewById(R.id.familyname);
                bonusItemView = itemView.findViewById(R.id.familybonus);
                mCardView = itemView.findViewById(R.id.card);
                mFamilyBox = itemView.findViewById(R.id.familylayout);
            }
        
            public void bindName(String name, Drawable drawable) {
                nameItemView.setText(name);
                nameItemView.setBackground(drawable);
            }
            public void bindPrice(int price) {
                priceItemView.setText(String.valueOf(price));
            }
            public void bindBonusCard(String cardName) {bonusCardItemView.setText(cardName);};
            public void bindBonus(int bonus) {bonusItemView.setText(String.valueOf(bonus));}
            public void bindIsActive(boolean isActive) {mCardView.setActivated(isActive);}
        
            static CivicsViewHolder create(ViewGroup parent) {
                View view = LayoutInflater.from(parent.getContext())
                        .inflate(R.layout.item_row, parent, false);
                return new CivicsViewHolder(view);
            }
        
            public ItemDetailsLookup.ItemDetails<String> getItemDetails() {
                return new MyItemDetails(getBindingAdapterPosition(), nameItemView.getText().toString());
            }
        }

I think my problem comes from the way the data changes. I have a tracker defined and multiselect items in the recycler. This in turn changes the data itself which should result in items getting grayed out when they get to expensive for the remaining treasure. Now I tried doing the notifyDataChanged inside the trackers observer methods, but that lets to end endless recursion until an exception gets called from what I guess the recursion never ends, as every call of notify from inside the tracker observers onItemStateChanged seems to call the method itself again.

So I am a bit clueless WHERE I should call the notifyDataChanged method without causing this endless recursion.

No matter where I try to add the line mAdapter.notfiyDataChanged; to, it always results in endless log lines like this until the app crashes the moment I select the first item of the recyclerview.

1970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.519 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.519 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onItemStateChanged : Mysticism
2022-06-04 11:36:24.519 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.519 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.519 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onSelectionChanged
2022-06-04 11:36:24.520 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: treasure remaining : 150
2022-06-04 11:36:24.520 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onSelectionRefresh
2022-06-04 11:36:24.520 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.520 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.520 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onItemStateChanged : Mysticism
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onSelectionChanged
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: treasure remaining : 150
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onSelectionRefresh
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.521 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onItemStateChanged : Mysticism
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onSelectionChanged
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: treasure remaining : 150
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onSelectionRefresh
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider :Mysticism
2022-06-04 11:36:24.522 11970-11970/org.tesira.mturba.civichelper V/MODEL: inside getPosition von MyItemKeyProvider Position :1
2022-06-04 11:36:24.523 11970-11970/org.tesira.mturba.civichelper V/OBSERVER: inside onItemStateChanged : Mysticism

The Log.v I added shows a bit where the programs run around in circles. When I comment the notify line out, everything starts working again besides putting the shading right on the visible items. This last try is from putting the notify line into the observer of the remaining treasure (as its LiveData, right corner of screenshot above).

The notify works ONCE when I put it into the observer of the treasure and enter a different value for treasure (left upper corner of screenshot).

        tracker = new SelectionTracker.Builder<>(
                "my-selection-id",
                mRecyclerView,
                myItemKeyProvider,
                new MyItemDetailsLookup(mRecyclerView),
                    StorageStrategy.createStringStorage())
                    .withSelectionPredicate(new MySelectionPredicate<>(this, mCivicViewModel))
                    .build();
        mAdapter.setSelectionTracker(tracker);
        mAdapter.setCivicViewModel(mCivicViewModel);

        tracker.addObserver(new SelectionTracker.SelectionObserver<String>() {
            @Override
            public void onItemStateChanged(@NonNull String key, boolean selected) {
                super.onItemStateChanged(key, selected);
                // item selection changed, we need to redo total selected cost
                Log.v("OBSERVER", "inside onItemStateChanged : " + key);
                mCivicViewModel.calculateTotal(tracker.getSelection());
            }

            @Override
            public void onSelectionRefresh() {
                super.onSelectionRefresh();
                Log.v("OBSERVER", "inside onSelectionRefresh");
            }

            @Override
            public void onSelectionChanged() {
                super.onSelectionChanged();
                Log.v("OBSERVER", "inside onSelectionChanged");
            }

            @Override
            public void onSelectionRestored() {
                super.onSelectionRestored();
                Log.v("OBSERVER", "inside onSelectionRestored");
            }

        });

this is from my ViewModel the method to redo the remaing treasure which is called from the tracker everytime a selection changes.

    /**
     * Calculates the sum of all currently selected advances during the buy process and
     * updates remaining treasure.
     * @param selection Currently selected cards from the View.
     */
    public void calculateTotal(Selection<String> selection) {
        int newTotal = 0;
        for (String name : selection) {
            Card adv = getAdvanceByName(name);
            newTotal += adv.getCurrentPrice();
        }
        this.remaining.setValue(treasure.getValue() - newTotal);
    }
Tupsi
  • 33
  • 6

2 Answers2

0

I think the problem is that you do not update the view of a single ViewHolder correctly. Maybe a notifyItemChanged call is missing to trigger the update of an ViewHolder.

Also to trigger updates on the other ViewHolders, the adapter must hold the current total value. Each ViewHolder must report to the adapter if it is clicked, the adapter then updates it state and triggers an update on all ViewHolder providing the new total value.


Update after on click:

     holder.itemView.setOnClickListener(v -> {
            tracker.select(name);
            
            if (!tracker.isSelected(name) && mCivicViewModel.getRemaining().getValue() < price) {
                holder.mCardView.setBackgroundResource(R.color.dark_grey);
                holder.mCardView.setAlpha(0.5F);
            } else {
                holder.mCardView.setBackgroundResource(R.drawable.item_background);
                holder.mCardView.setAlpha(1F);
            }
        }
    );

Daniel Knauf
  • 559
  • 3
  • 11
  • I will post my code when I am back home at my computer. Thanks for the help offering! – Tupsi May 16 '22 at 09:03
  • sorry, took me a while, corona had found me in the end as well. I tried updating with notifyItemChanged, but was not able to find the right place todo it. – Tupsi Jun 02 '22 at 12:46
  • the adapter would have access to the total value through my ViewModel, I store the value there. But I still do not understand where I would trigger the update from inside the adapter as you mentioned it. Is there some OnSomething event I am not aware of or how would one do that? – Tupsi Jun 04 '22 at 11:41
  • So the `CivicViewModel ` must be updated when a new value is chosen, right? If so the `ViewModel` must have a `public method` with takes the value to add and is called inside the adapter when an entry is chosen. Probably also needs a method to decrease the total value if an item unselected. – Daniel Knauf Jun 05 '22 at 00:10
  • I am already doing that. I added the section of my fragment where it happens to the original post above (please have a look). I have a tracker for the multiselect and calling the public method from the ViewModel in the onItemStateChanged method. But I can NOT do the notify there as this leads to infinite triggers of that onItemStateChanged until an exception happens. Although I do not understand what triggers these new ItemChanged events, as from my POV there is no new change. – Tupsi Jun 05 '22 at 18:02
  • I still do not get where I would call that method from inside the adapter. Can you give me an example? Maybe that will already fix it (moving the method call from the tracker into the adapter and adding the notify again). – Tupsi Jun 05 '22 at 18:06
  • Thanks for providing all code. I did not realise that you use `SelectionTracker`. I am not familiar with it, but by reading examples it looks like your calculation should be done in `onSelectionChanged ` and they all set `stableIds` to `true` for the `RecyclerView`. – Daniel Knauf Jun 05 '22 at 23:41
  • I do not fully understand how the selection updates the `RecyclerView`, but `notifyItemChanged` does not has to be triggered manually it seems. He also changes the background when an item is selected but I do not find how he does it: https://proandroiddev.com/a-guide-to-recyclerview-selection-3ed9f2381504 – Daniel Knauf Jun 05 '22 at 23:43
  • I used the guide you linked as a starting point to build my selection tracking in my recycler view. As a beginner with android app, that one was very hard, but it worked in the end, leaving me "only" with the problem described above. – Tupsi Jun 06 '22 at 12:26
  • I have not yet build a sample project with a `SelectionTracker` to further analyse what happens when a `ViewHolder` is selected but from all articles it seems that `onBindViewHolder` is triggered again. All articles just check `tracker.isSelected()` in `onBindViewHolder` and set the background accordingly. A second option would be to update the background in the `onClickListener` after `tracker.select(name) ` you would extract the selection again an update the background. I will add the code snippet to my answer. – Daniel Knauf Jun 06 '22 at 23:38
  • Thanks for you snippet. Sadly I already found out that the onClickListener for an Item is only fired the first time you click the list. Afterwards the whole Multiselect thing of the selection-tracker takes over and the all items you select after the first do NOT trigger another onClickListener event. You can see above in my code that I have a toast there, you only see that for the first item clicked. Anyway, it wouldnt help my problem as shading the selected item already works fine. I need to shade alle other items I can currently see if the remaining price isn't enough. – Tupsi Jun 07 '22 at 06:31
  • But I got an idea with you mentioning that every click triggers running the onBinViewHolder at least for the item selected. I should be able to call a method redoing all visible items from there, if I ever find out how todo that. Running from firstvisible to last doesnt seem to work, already tried that (with the layoutmanager methods first and last visible). – Tupsi Jun 07 '22 at 06:38
0

The answer is in here. All the approaches we tried here with any notfiyItem only leads to recurring infinite loops ending in a crash.

This though works:

Why does LinearLayoutManager.childAt() returns null?

In the end, I had to manually recreate what I already do in the adapter (shading the item if one can not longer effort it) in a seperate method where I loop through all currently visible items. (see link above for a code example). Now this does not trigger the endless loop, no matter if I put the call to refresh into the tracker selection observer, or the remaining treasure observer. Works in either places just fine Thanks Daniel for the pointers and help offered. Learned a lot.

Here is my code for the update for reference:

    private void updateViews() {
        LinearLayoutManager lm = (LinearLayoutManager) mRecyclerView.getLayoutManager();
        // Get adapter positions for first and last visible items on screen.
        int firstVisible = lm.findFirstVisibleItemPosition();
        int lastVisible = lm.findLastVisibleItemPosition();
        for (int i = firstVisible; i <= lastVisible; i++) {
            // Find the view that corresponds to this position in the adapter.
            View visibleView = lm.findViewByPosition(i);
            TextView priceText = visibleView.findViewById(R.id.price);
            TextView nameText = visibleView.findViewById(R.id.name);
            String name = nameText.getText().toString();
            boolean isSelected = tracker.isSelected(name);
            int price = Integer.parseInt(priceText.getText().toString());
            View mCardView = visibleView.findViewById(R.id.card);
            if (!isSelected) {
                if (price > mCivicViewModel.getRemaining().getValue()) {
                    mCardView.setAlpha(0.5F);
                } else {
                    mCardView.setAlpha(1.0F);
                }
            }
        }
    }

Tupsi
  • 33
  • 6