Mateusz Maciejko
Mateusz Maciejko

Reputation: 29

Infinite loop of TextWatchers methods on RecyclerView Adapter

I'm trying to achive simple functionality of converting integers into decimal and vice versa (its simplication of my more advanced problem), on a single element of RecyclerView, live upon every user input. The problem is that when i call RecyclerView.Adapter.notifyItemChanged() inside of TextWatcher.afterTextChanged() it ends up in an infinite loop of TextWatcher's methods. Is there any way to achive this kind of behaviour? I want to update my orginal item inside TextWatcher.afterTextChanged().

Code below:

public class TestElementAdapter extends RecyclerView.Adapter<TestElementAdapter.TestElementViewHolder> {
    private final Context context;
    private final RecyclerView recyclerView;
    private List<TestElement> testElements;

    public TestElementAdapter(Context context, List<TestElement> testElements, RecyclerView recyclerView) {
        this.context = context;
        this.testElements = testElements;
        this.recyclerView = recyclerView;
        testElements.add(new TestElement(1, 1.0F));
    }

    private class MyTextWatcher<T> implements TextWatcher {
        int position = 0;
        BiConsumer<TestElement, T> consumer;
        Function<String, T> mappingFunction;

        public MyTextWatcher(BiConsumer<TestElement, T> consumer, Function<String, T> mappingFunction) {
            this.consumer = consumer;
            this.mappingFunction = mappingFunction;
        }

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
        }

        @Override
        public void afterTextChanged(Editable s) {
            if (s == null || s.length() == 0) {
                return;
            }

            TestElement testElement = testElements.get(position);
            consumer.accept(testElement, mappingFunction.apply(s.toString()));
            TestElementAdapter.this.recyclerView.post(() -> notifyItemChanged(position));

            System.out.println("afterTextChanged");
        }

        public void updatePosition(int adapterPosition) {
            this.position = adapterPosition;
        }
    }

    @NonNull
    @Override
    public TestElementViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.test_element, parent, false);
        TestElementViewHolder holder = new TestElementViewHolder(view);
        holder.initTextWatchers();
        return holder;
    }

    @Override
    public void onBindViewHolder(@NonNull TestElementViewHolder holder, int position) {
        TestElement testElement = testElements.get(position);

        holder.integer.getText().clear();
        holder.decimal.getText().clear();

        holder.integer.append(testElement.getInteger() + "");
        holder.decimal.append(testElement.getDecimal() + "");

        holder.watchers.forEach(watcher -> watcher.updatePosition(holder.getAdapterPosition()));
    }

    @Override
    public int getItemCount() {
        return testElements.size();
    }

    public static class TestElement {
        private Integer integer;
        private Float decimal;

        public TestElement(Integer integer, Float decimal) {
            this.integer = integer;
            this.decimal = decimal;
        }

        public Integer getInteger() { return integer; }
        public Float getDecimal() { return decimal; }
        public void updateByInteger(Integer integer) {
            this.integer = integer;
            this.decimal = integer * 1.0F;
        }
        public void updateByDecimal(Float decimal) {
            this.decimal = decimal;
            this.integer = decimal.intValue();
        }
    }

    public class TestElementViewHolder extends RecyclerView.ViewHolder {
        EditText integer;
        EditText decimal;
        List<MyTextWatcher<?>> watchers = new ArrayList<>();
        private MyTextWatcher<Integer> integerWatcher;
        private MyTextWatcher<Float> decimalWatcher;


        public TestElementViewHolder(@NonNull View view) {
            super(view);

            integer = view.findViewById(R.id.integerNumber);
            decimal = view.findViewById(R.id.decimalNumber);
        }

        public void initTextWatchers() {
            MyTextWatcher<Integer> integerWatcher = new MyTextWatcher<>(this, TestElement::updateByInteger, Integer::parseInt);
            integer.addTextChangedListener(integerWatcher);
            this.integerWatcher = integerWatcher;

            MyTextWatcher<Float> decimalWatcher = new MyTextWatcher<>(this, TestElement::updateByDecimal, Float::parseFloat);
            decimal.addTextChangedListener(decimalWatcher);
            this.decimalWatcher = decimalWatcher;

            watchers.add(this.integerWatcher);
            watchers.add(this.decimalWatcher);
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        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="wrap_content"
        android:background="@color/white"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:paddingLeft="@dimen/activity_horizontal_margin"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:theme="@style/ThemeOverlay.AppCompat.Light"
        android:orientation="vertical"
        android:gravity="top">


    <LinearLayout
            android:layout_height="wrap_content"
            android:layout_width="match_parent"
            android:orientation="horizontal"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" android:id="@+id/linearLayout2">

        <EditText
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:inputType="number"
                android:padding="8dp"
                android:id="@+id/integerNumber"
                android:gravity="center"
                android:layout_weight="1"
        />
        <EditText
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:inputType="numberDecimal"
                android:padding="8dp"
                android:id="@+id/decimalNumber"
                android:gravity="center"
                android:layout_weight="1"
        />
    </LinearLayout>
</LinearLayout>

UPDATE:

I added methods to disable and enable TextWatchers in ViewHolder. I disable them line before notifyItemChanged and then enable them in onBindViewHolder.

public void disableWatchers() {
            integer.removeTextChangedListener(integerWatcher);
            decimal.removeTextChangedListener(decimalWatcher);
        }

        public void enableWatchers() {
            integer.addTextChangedListener(integerWatcher);
            decimal.addTextChangedListener(decimalWatcher);
        }

It worked for me.

I also changed call to notifyItemChanged from

TestElementAdapter.this.recyclerView.post(() -> notifyItemChanged(position)) to TestElementAdapter.this.notifyItemChanged(position).

Calling this method from UI thread made EditText not responsive enough. It also works fine, but only for amount of elements that all can fit on my RecyclerView, if there are more elements im getting exception: java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling.... Is there any way to leave call TestElementAdapter.this.notifyItemChanged(position) in TextWatcher.afterTextChanged and avoid this error?

Updated Adapter code:

public class TestElementAdapter extends RecyclerView.Adapter<TestElementAdapter.TestElementViewHolder> {
    private final Context context;
    private final RecyclerView recyclerView;
    private List<TestElement> testElements;

    public TestElementAdapter(Context context, List<TestElement> testElements, RecyclerView recyclerView) {
        this.context = context;
        this.testElements = testElements;
        this.recyclerView = recyclerView;
        IntStream.range(0, 20).forEach(v -> testElements.add(new TestElement(1, 1.0F)));
    }

    private class MyTextWatcher<T> implements TextWatcher {
        int position = 0;
        TestElementViewHolder viewHolder;
        BiConsumer<TestElement, T> consumer;
        Function<String, T> mappingFunction;

        public MyTextWatcher(TestElementViewHolder viewHolder, BiConsumer<TestElement, T> consumer, Function<String, T> mappingFunction) {
            this.viewHolder = viewHolder;
            this.consumer = consumer;
            this.mappingFunction = mappingFunction;
        }

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
        }

        @Override
        public void afterTextChanged(Editable s) {
            if (s == null || s.length() == 0) {
                return;
            }

            TestElement testElement = testElements.get(position);
            consumer.accept(testElement, mappingFunction.apply(s.toString()));
            viewHolder.disableWatchers();
            TestElementAdapter.this.notifyItemChanged(position);

            System.out.println("afterTextChanged");
        }

        public void updatePosition(int adapterPosition) {
            this.position = adapterPosition;
        }
    }

    @NonNull
    @Override
    public TestElementViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.test_element, parent, false);
        TestElementViewHolder holder = new TestElementViewHolder(view);
        holder.initTextWatchers();
        holder.disableWatchers();
        return holder;
    }

    @Override
    public void onBindViewHolder(@NonNull TestElementViewHolder holder, int position) {
        TestElement testElement = testElements.get(position);

        holder.integer.getText().clear();
        holder.decimal.getText().clear();

        holder.integer.append(testElement.getInteger() + "");
        holder.decimal.append(testElement.getDecimal() + "");

        holder.watchers.forEach(watcher -> watcher.updatePosition(holder.getAdapterPosition()));
        holder.enableWatchers();
    }

    @Override
    public int getItemCount() {
        return testElements.size();
    }

    public static class TestElement {
        private Integer integer;
        private Float decimal;

        public TestElement(Integer integer, Float decimal) {
            this.integer = integer;
            this.decimal = decimal;
        }

        public Integer getInteger() { return integer; }
        public Float getDecimal() { return decimal; }
        public void updateByInteger(Integer integer) {
            this.integer = integer;
            this.decimal = integer * 1.0F;
        }
        public void updateByDecimal(Float decimal) {
            this.decimal = decimal;
            this.integer = decimal.intValue();
        }
    }

    public class TestElementViewHolder extends RecyclerView.ViewHolder {
        EditText integer;
        EditText decimal;
        List<MyTextWatcher<?>> watchers = new ArrayList<>();
        private MyTextWatcher<Integer> integerWatcher;
        private MyTextWatcher<Float> decimalWatcher;


        public TestElementViewHolder(@NonNull View view) {
            super(view);

            integer = view.findViewById(R.id.integerNumber);
            decimal = view.findViewById(R.id.decimalNumber);
        }

        public void initTextWatchers() {
            MyTextWatcher<Integer> integerWatcher = new MyTextWatcher<>(this, TestElement::updateByInteger, Integer::parseInt);
            integer.addTextChangedListener(integerWatcher);
            this.integerWatcher = integerWatcher;

            MyTextWatcher<Float> decimalWatcher = new MyTextWatcher<>(this, TestElement::updateByDecimal, Float::parseFloat);
            decimal.addTextChangedListener(decimalWatcher);
            this.decimalWatcher = decimalWatcher;

            watchers.add(this.integerWatcher);
            watchers.add(this.decimalWatcher);
        }

        public void disableWatchers() {
            integer.removeTextChangedListener(integerWatcher);
            decimal.removeTextChangedListener(decimalWatcher);
        }

        public void enableWatchers() {
            integer.addTextChangedListener(integerWatcher);
            decimal.addTextChangedListener(decimalWatcher);
        }
    }
}

Upvotes: 1

Views: 671

Answers (1)

Zain
Zain

Reputation: 40878

The reason that the listernes duplicates is that you call notifyItemChanged() you add a new listener while keeping the current listener of the current item active after calling notifyItemChanged().

To solve this you need to disable the listeners before calling notifyItemChanged() using editText.removeTextChangedListener() and then re-enable them again with editText.addTextChangedListener().

Then you got

IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling

This is a common issue that you can solve by posting recyclerView changes main thread looper using.

But make sure that you added the listeners within this post to be synched after the recyclerView changes, here is the change:

@Override
public void afterTextChanged(Editable s) {
    if (s == null || s.length() == 0) {
        return;
    }

    TestElement testElement = testElements.get(position);
    consumer.accept(testElement, mappingFunction.apply(s.toString()));
    
    // remove the listeners
    viewHolder.disableWatchers();

    recyclerView.post(() -> {
        TestElementAdapter.this.notifyItemChanged(position)
        // add the listeners
        viewHolder.enableWatchers();
    });

    System.out.println("afterTextChanged");
}

And here is the entire class with that change


public class TestElementAdapter extends RecyclerView.Adapter<TestElementAdapter.TestElementViewHolder> {
    private final Context context;
    private final RecyclerView recyclerView;
    private List<TestElement> testElements;

    public TestElementAdapter(Context context, List<TestElement> testElements, RecyclerView recyclerView) {
        this.context = context;
        this.testElements = testElements;
        this.recyclerView = recyclerView;
        IntStream.range(0, 20).forEach(v -> testElements.add(new TestElement(1, 1.0F)));
    }

    private class MyTextWatcher<T> implements TextWatcher {
        int position = 0;
        TestElementViewHolder viewHolder;
        BiConsumer<TestElement, T> consumer;
        Function<String, T> mappingFunction;

        public MyTextWatcher(TestElementViewHolder viewHolder, BiConsumer<TestElement, T> consumer, Function<String, T> mappingFunction) {
            this.viewHolder = viewHolder;
            this.consumer = consumer;
            this.mappingFunction = mappingFunction;
        }

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
        }

        @Override
        public void afterTextChanged(Editable s) {
            if (s == null || s.length() == 0) {
                return;
            }

            TestElement testElement = testElements.get(position);
            consumer.accept(testElement, mappingFunction.apply(s.toString()));
            viewHolder.disableWatchers();
            
            recyclerView.post(() -> {
                TestElementAdapter.this.notifyItemChanged(position)
                viewHolder.enableWatchers();
            });

            System.out.println("afterTextChanged");
        }

        public void updatePosition(int adapterPosition) {
            this.position = adapterPosition;
        }
    }

    @NonNull
    @Override
    public TestElementViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.test_element, parent, false);
        TestElementViewHolder holder = new TestElementViewHolder(view);
        holder.initTextWatchers();
        holder.disableWatchers();
        return holder;
    }

    @Override
    public void onBindViewHolder(@NonNull TestElementViewHolder holder, int position) {
        TestElement testElement = testElements.get(position);

        holder.integer.getText().clear();
        holder.decimal.getText().clear();

        holder.integer.append(testElement.getInteger() + "");
        holder.decimal.append(testElement.getDecimal() + "");

        holder.watchers.forEach(watcher -> watcher.updatePosition(holder.getAdapterPosition()));
        holder.enableWatchers();
    }

    @Override
    public int getItemCount() {
        return testElements.size();
    }

    public static class TestElement {
        private Integer integer;
        private Float decimal;

        public TestElement(Integer integer, Float decimal) {
            this.integer = integer;
            this.decimal = decimal;
        }

        public Integer getInteger() { return integer; }
        public Float getDecimal() { return decimal; }
        public void updateByInteger(Integer integer) {
            this.integer = integer;
            this.decimal = integer * 1.0F;
        }
        public void updateByDecimal(Float decimal) {
            this.decimal = decimal;
            this.integer = decimal.intValue();
        }
    }

    public class TestElementViewHolder extends RecyclerView.ViewHolder {
        EditText integer;
        EditText decimal;
        List<MyTextWatcher<?>> watchers = new ArrayList<>();
        private MyTextWatcher<Integer> integerWatcher;
        private MyTextWatcher<Float> decimalWatcher;


        public TestElementViewHolder(@NonNull View view) {
            super(view);

            integer = view.findViewById(R.id.integerNumber);
            decimal = view.findViewById(R.id.decimalNumber);
        }

        public void initTextWatchers() {
            MyTextWatcher<Integer> integerWatcher = new MyTextWatcher<>(this, TestElement::updateByInteger, Integer::parseInt);
            integer.addTextChangedListener(integerWatcher);
            this.integerWatcher = integerWatcher;

            MyTextWatcher<Float> decimalWatcher = new MyTextWatcher<>(this, TestElement::updateByDecimal, Float::parseFloat);
            decimal.addTextChangedListener(decimalWatcher);
            this.decimalWatcher = decimalWatcher;

            watchers.add(this.integerWatcher);
            watchers.add(this.decimalWatcher);
        }

        public void disableWatchers() {
            integer.removeTextChangedListener(integerWatcher);
            decimal.removeTextChangedListener(decimalWatcher);
        }

        public void enableWatchers() {
            integer.addTextChangedListener(integerWatcher);
            decimal.addTextChangedListener(decimalWatcher);
        }
    }
}

Upvotes: 1

Related Questions