CursorAdapter поддерживает ListView удалить анимацию "мерцает" при удалении

Я пытаюсь реализовать пролистывание, чтобы удалить и в ListView используя библиотеку https://github.com/timroes/SwipeToDismissUndoList, которая расширяет образец SwipeToDismiss Романа Нурика.

Моя проблема в анимации удаления. Так как ListView поддерживается CursorAdapterанимация вызывает onDismiss обратный звонок в onAnimationEnd но это означает, что анимация запустилась и сбросилась до CursorAdapter Обновления с удалением.

В конечном итоге это выглядит как мерцание для пользователя, когда он удаляет заметку, проводя по ней, затем представление возвращается на долю секунды, а затем исчезает, потому что CursorAdapter подобрал изменение данных.

Вот мой OnDismissCallback:

private SwipeDismissList.OnDismissCallback dismissCallback = 
        new SwipeDismissList.OnDismissCallback() {
    @Override
    public SwipeDismissList.Undoable onDismiss(ListView listView, final int position) {
        Cursor c = mAdapter.getCursor();
        c.moveToPosition(position);
        final int id = c.getInt(Query._ID);
        final Item item = Item.findById(getActivity(), id);
        if (Log.LOGV) Log.v("Deleting item: " + item);

        final ContentResolver cr = getActivity().getContentResolver();
        cr.delete(Items.buildItemUri(id), null, null);
        mAdapter.notifyDataSetChanged();

        return new SwipeDismissList.Undoable() {
            public void undo() {
                if (Log.LOGV) Log.v("Restoring Item: " + item);
                ContentValues cv = new ContentValues();
                cv.put(Items._ID, item.getId());
                cv.put(Items.ITEM_CONTENT, item.getContent());
                cr.insert(Items.CONTENT_URI, cv);
            }
        };
    }
};

7 ответов

Решение

Я знаю, что этот вопрос помечен как "отвеченный", но, как я уже отмечал в комментариях, проблема с использованием MatrixCursor заключается в том, что он слишком неэффективен. Копирование всех строк, за исключением строки, подлежащей удалению, означает, что удаление строки выполняется за линейное время (линейно по количеству элементов в просмотре списка). Для больших данных и более медленных телефонов это, вероятно, недопустимо.

Альтернативный подход заключается в реализации собственного AbstractCursor, который игнорирует удаляемую строку. Это приводит к тому, что удаление фиктивной строки выполняется в постоянное время и с незначительным снижением производительности при рисовании.

Пример реализации:

public class CursorWithDelete extends AbstractCursor {

private Cursor cursor;
private int posToIgnore;

public CursorWithDelete(Cursor cursor, int posToRemove)
{
    this.cursor = cursor;
    this.posToIgnore = posToRemove;
}

@Override
public boolean onMove(int oldPosition, int newPosition)
{
    if (newPosition < posToIgnore)
    {
        cursor.moveToPosition(newPosition);
    }
    else
    {
        cursor.moveToPosition(newPosition+1);
    }
    return true;
}

@Override
public int getCount()
{
    return cursor.getCount() - 1;
}

@Override
public String[] getColumnNames()
{
    return cursor.getColumnNames();
}

//etc.
//make sure to override all methods in AbstractCursor appropriately

Выполните все шаги, как прежде, за исключением:

  • В SwipeDismissList.OnDismissCallback.onDismiss() создайте новый CursorWithDelete.
  • поменять местами новый курсор

Я думаю, что SwipeToDismissUndoList не подходит для адаптеров на основе курсора. Поскольку адаптеры зависят от изменений от поставщиков контента (setNotificationUri() или же registerContentObserver() …) Обновить интерфейс. Вы не знаете, когда данные доступны или нет. Это проблема, с которой вы сталкиваетесь.

Я думаю, что есть что-то вроде трюка. Ты можешь использовать MatrixCursor,

  • В onLoadFinished(Loader, Cursor) вы сохраняете ссылку на курсор, возвращаемый поставщиком контента. Вам нужно закрыть его позже вручную.
  • В SwipeDismissList.OnDismissCallback.onDismiss(), создать новый MatrixCursor, скопируйте все элементы из текущего курсора, кроме элементов, которые удаляются.
  • Установите вновь созданный матричный курсор на адаптер с помощью swapCursor() (не changeCursor()). Так как swapCursor() не закрывает старый курсор Вы должны держать его открытым, чтобы загрузчик работал правильно.
  • Теперь пользовательский интерфейс обновлен, вы звоните getContentResolver().delete() и фактически удалить элементы, которые пользователь хотел удалить. Когда поставщик контента заканчивает удаление данных, он уведомляет исходный курсор о перезагрузке данных.
  • Обязательно закройте исходный курсор, который вы меняли. Например:

    private Cursor mOrgCursor;
    
    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        if (mOrgCursor != null)
            mOrgCursor.close();
        mOrgCursor = data;
        mAdapter.changeCursor(mOrgCursor);
    }
    
    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
        if (mOrgCursor != null) {
            mOrgCursor.close();
            mOrgCursor = null;
        }
        mAdapter.changeCursor(null);
    }
    
  • Не беспокойся о матричном курсоре, changeCursor() закрою это.

На момент публикации этого ответа я перепробовал все перечисленные подходы из этой темы. CursorWrapper наиболее эффективен с точки зрения производительности, но, к сожалению, небезопасен, поскольку нет гарантии того, что положение отклоненного элемента стабильно (если данные могут быть изменены из другого источника, например, путем фоновой синхронизации). С другой стороны, вы можете попробовать мою простую реализацию адаптера базового курсора:

/*
 * Copyright (C) 2014. Victor Kosenko (http://qip-blog.eu.org)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

// your package here

import android.content.Context;
import android.database.Cursor;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorAdapter;

import com.google.api.client.util.Sets;

import java.util.Set;

/**
 * This is basic implementation of swipable cursor adapter that allows to skip displaying dismissed
 * items by replacing them with empty view. This adapter overrides default implementation of
 * {@link #getView(int, android.view.View, android.view.ViewGroup)}, so if you have custom
 * implementation of this method you should review it according to logic of this adapter.
 *
 * @author Victor Kosenko
 */
public abstract class BaseSwipableCursorAdapter extends CursorAdapter {

    protected static final int VIEW_ITEM_NORMAL = 0;
    protected static final int VIEW_ITEM_EMPTY = 1;

    protected Set<Long> pendingDismissItems;
    protected View emptyView;
    protected LayoutInflater inflater;

    /**
     * If {@code true} all pending items will be removed on cursor swap
     */
    protected boolean flushPendingItemsOnSwap = true;

    /**
     * @see android.widget.CursorAdapter#CursorAdapter(android.content.Context, android.database.Cursor, boolean)
     */
    public BaseSwipableCursorAdapter(Context context, Cursor c, boolean autoRequery) {
        super(context, c, autoRequery);
        init(context);
    }

    /**
     * @see android.widget.CursorAdapter#CursorAdapter(android.content.Context, android.database.Cursor, int)
     */
    protected BaseSwipableCursorAdapter(Context context, Cursor c, int flags) {
        super(context, c, flags);
        init(context);
    }

    /**
     * Constructor with {@code null} cursor and enabled autoRequery
     *
     * @param context The context
     */
    protected BaseSwipableCursorAdapter(Context context) {
        super(context, null, true);
        init(context);
    }

    /**
     * @param context                 The context
     * @param flushPendingItemsOnSwap If {@code true} all pending items will be removed on cursor swap
     * @see #BaseSwipableCursorAdapter(android.content.Context)
     */
    protected BaseSwipableCursorAdapter(Context context, boolean flushPendingItemsOnSwap) {
        super(context, null, true);
        init(context);
        this.flushPendingItemsOnSwap = flushPendingItemsOnSwap;
    }

    protected void init(Context context) {
        inflater = LayoutInflater.from(context);
        pendingDismissItems = Sets.newHashSet();
        emptyView = new View(context);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        if (!getCursor().moveToPosition(position)) {
            throw new IllegalStateException("couldn't move cursor to position " + position);
        }
        if (isPendingDismiss(position)) {
            return emptyView;
        } else {
            return super.getView(position, convertView, parent);
        }
    }

    @Override
    public int getViewTypeCount() {
        return 2;
    }

    @Override
    public int getItemViewType(int position) {
        return pendingDismissItems.contains(getItemId(position)) ? VIEW_ITEM_EMPTY : VIEW_ITEM_NORMAL;
    }

    /**
     * Add item to pending dismiss. This item will be ignored in
     * {@link #getView(int, android.view.View, android.view.ViewGroup)} when displaying list of items
     *
     * @param id Id of item that needs to be added to pending for dismiss
     * @return {@code true} if this item already in collection if pending items, {@code false} otherwise
     */
    public boolean putPendingDismiss(Long id) {
        return pendingDismissItems.add(id);
    }

    /**
     * Confirm that specified item is no longer present in underlying cursor. This method should be
     * called after the fact of removing this item from result set of underlying cursor.
     * If you're using flushPendingItemsOnSwap flag there is no need to call this method.
     *
     * @param id Id of item
     * @return {@code true} if this item successfully removed from pending to dismiss, {@code false}
     * if it's not present in pending items collection
     */
    public boolean commitDismiss(Long id) {
        return pendingDismissItems.remove(id);
    }

    /**
     * Check if this item should be ignored
     *
     * @param position Cursor position
     * @return {@code true} if this item should be ignored, {@code false} otherwise
     */
    public boolean isPendingDismiss(int position) {
        return getItemViewType(position) == VIEW_ITEM_EMPTY;
    }

    public boolean isFlushPendingItemsOnSwap() {
        return flushPendingItemsOnSwap;
    }

    /**
     * Automatically flush pending items when calling {@link #swapCursor(android.database.Cursor)}
     *
     * @param flushPendingItemsOnSwap If {@code true} all pending items will be removed on cursor swap
     */
    public void setFlushPendingItemsOnSwap(boolean flushPendingItemsOnSwap) {
        this.flushPendingItemsOnSwap = flushPendingItemsOnSwap;
    }

    @Override
    public Cursor swapCursor(Cursor newCursor) {
        if (flushPendingItemsOnSwap) {
            pendingDismissItems.clear();
        }
        return super.swapCursor(newCursor);
    }
}

Он основан на HashSet и идентификаторе элемента по умолчанию (getItemId()), поэтому производительность не должна быть проблемой, поскольку метод contains() имеет сложность времени O(1) и фактически установленный параметр будет содержать ноль или один элемент большую часть времени. Также это зависит от гуавы. Если вы не используете гуаву, просто замените конструкцию набора в строке 91.

Чтобы использовать его в своем проекте, вы можете просто расширить этот класс вместо CursorAdapter и добавить несколько строк кода в onDismiss() (если вы используете EnhancedListView или похожую библиотеку):

@Override
public EnhancedListView.Undoable onDismiss(EnhancedListView enhancedListView, int i) {
    adapter.putPendingDismiss(id);
    adapter.notifyDataSetChanged();
    ...
}

Это решение не будет работать, если вы используете разделители списка (потому что этот адаптер отображает пустое представление вместо отклоненного элемента). Вы должны добавить поля в макете вашего элемента, чтобы сделать интервалы между элементами и включить разделитель в макете элемента.

Этот код может быть обновлен в будущем, поэтому я разместил его на github gist: https://gist.github.com/q1p/0b95633ab9367fb86785

Также я хочу порекомендовать вам не использовать операции ввода / вывода в основном потоке, как в вашем примере:)

Просто приходите сюда с той же проблемой и решайте ее легко и просто с помощью кода, предоставленного Эмануэлем Меклином.

Это действительно просто: внутри метода onDismiss сделайте это:

        //Save cursor for later
        Cursor cursor = mAdapter.getCursor();
        SwipeToDeleteCursorWrapper cursorWrapper = new SwipeToDeleteCursorWrapper(mAdapter.getCursor(), reverseSortedPositions[0]);
        mAdapter.swapCursor(cursorWrapper);
        //Remove the data from the database using the cursor

А затем создайте SwipteToDeleteCursorWrapper, как написал Эмануэль:

public class SwipeToDeleteCursorWrapper extends CursorWrapper
{
    private int mVirtualPosition;
    private int mHiddenPosition;

    public SwipeToDeleteCursorWrapper(Cursor cursor, int hiddenPosition)
    {
        super(cursor);
        mVirtualPosition = -1;
        mHiddenPosition = hiddenPosition;
    }

    @Override
    public int getCount()
    {
        return super.getCount() - 1;
    }

    @Override
    public int getPosition()
    {
        return mVirtualPosition;
    }

    @Override
    public boolean move(int offset)
    {
        return moveToPosition(getPosition() + offset);
    }

    @Override
    public boolean moveToFirst()
    {
        return moveToPosition(0);
    }

    @Override
    public boolean moveToLast()
    {
        return moveToPosition(getCount() - 1);
    }

    @Override
    public boolean moveToNext()
    {
        return moveToPosition(getPosition() + 1);
    }

    @Override
    public boolean moveToPosition(int position)
    {
        mVirtualPosition = position;
        int cursorPosition = position;
        if (cursorPosition >= mHiddenPosition)
        {
            cursorPosition++;
        }
        return super.moveToPosition(cursorPosition);
    }

    @Override
    public boolean moveToPrevious()
    {
        return moveToPosition(getPosition() - 1);
    }

    @Override
    public boolean isBeforeFirst()
    {
        return getPosition() == -1 || getCount() == 0;
    }

    @Override
    public boolean isFirst()
    {
        return getPosition() == 0 && getCount() != 0;
    }

    @Override
    public boolean isLast()
    {
        int count = getCount();
        return getPosition() == (count - 1) && count != 0;
    }

    @Override
    public boolean isAfterLast()
    {
        int count = getCount();
        return getPosition() == count || count == 0;
    }
}

Это все!

Эй, у меня была похожая проблема, и я решил ее следующим образом, надеюсь, она вам поможет:

Я использовал то, что Chet Haase показал в этом devbyte: http://www.youtube.com/watch?v=YCHNAi9kJI4

Это очень похоже на код Романа, но здесь он использует ViewTreeObserver, поэтому после того, как вы удалили элемент из адаптера, но до того, как список будет перерисован, у вас есть время анимировать, чтобы закрыть пробел, и он не будет мерцать. Другое отличие состоит в том, что он устанавливает прослушиватель для каждого представления (элемента) списка в адаптере, а не в самом ListView.

Итак, пример моего кода:

Это onCreate ListActivity, здесь я передаю слушателю адаптер ничего особенного:

ListAdapterTouchListener listAdapterTouchListener = new ListAdapterTouchListener(getListView());
    listAdapter = new ListAdapter(this,null,false,listAdapterTouchListener);

Вот часть ListAdapter (это мой собственный адаптер, который расширяет CursorAdapter), я передаю Listener в Конструкторе,

private View.OnTouchListener onTouchListener;

public ListAdapter(Context context, Cursor c, boolean autoRequery,View.OnTouchListener listener) {
    super(context, c, autoRequery);
    onTouchListener = listener;
}

а затем в методе newView я установил его в представление:

@Override
public View newView(final Context context, Cursor cursor, ViewGroup parent) {
    View view = layoutInflater.inflate(R.layout.list_item,parent,false);
    // here should be some viewholder magic to make it faster
    view.setOnTouchListener(onTouchListener);

    return view;
}

Слушатель в основном такой же, как в коде, показанном в видео, я не использую backgroundcontainer, но это только мой выбор. Таким образом, animateRemoval имеет то, что интересно, вот оно:

private void animateRemoval(View viewToRemove){
    for(int i=0;i<listView.getChildCount();i++){
        View child = listView.getChildAt(i);
        if(child!=viewToRemove){

        // since I don't have stableIds I use the _id from the sqlite database
        // I'm adding the id to the viewholder in the bindView method in the ListAdapter

            ListAdapter.ViewHolder viewHolder = (ListAdapter.ViewHolder)child.getTag();
            long itemId = viewHolder.id;
            itemIdTopMap.put(itemId, child.getTop());
        }
    }

    // I'm using content provider with LoaderManager in the activity because it's more efficient, I get the id from the viewholder

    ListAdapter.ViewHolder viewHolder = (ListAdapter.ViewHolder)viewToRemove.getTag();
    long removeId = viewHolder.id;

    //here you remove the item

    listView.getContext().getContentResolver().delete(Uri.withAppendedPath(MyContentProvider.CONTENT_ID_URI_BASE,Long.toString(removeId)),null,null);

    // after the removal get a ViewTreeObserver, so you can set a PredrawListener
    // the rest of the code is pretty much the same as in the sample shown in the video

    final ViewTreeObserver observer = listView.getViewTreeObserver();
    observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
        @Override
        public boolean onPreDraw() {
            observer.removeOnPreDrawListener(this);
            boolean firstAnimation = true;
            for(int i=0;i<listView.getChildCount();i++){
                final View child = listView.getChildAt(i);
                ListAdapter.ViewHolder viewHolder = (ListAdapter.ViewHolder)child.getTag();
                long itemId = viewHolder.id;
                Integer startTop = itemIdTopMap.get(itemId);
                int top = child.getTop();
                if(startTop!=null){
                    if (startTop!=top) {
                        int delta=startTop-top;
                        child.setTranslationY(delta);
                        child.animate().setDuration(MOVE_DURATION).translationY(0);
                        if(firstAnimation){
                            child.animate().setListener(new Animator.AnimatorListener() {
                                @Override
                                public void onAnimationStart(Animator animation) {

                                }

                                @Override
                                public void onAnimationEnd(Animator animation) {
                                        swiping=false;
                                    listView.setEnabled(true);
                                }

                                @Override
                                public void onAnimationCancel(Animator animation) {

                                }

                                @Override
                                public void onAnimationRepeat(Animator animation) {

                                }
                            });
                            firstAnimation=false;
                        }
                    }
                }else{
                    int childHeight = child.getHeight()+listView.getDividerHeight();
                    startTop = top+(i>0?childHeight:-childHeight);
                    int delta = startTop-top;
                    child.setTranslationY(delta);
                    child.animate().setDuration(MOVE_DURATION).translationY(0);
                    if(firstAnimation){
                        child.animate().setListener(new Animator.AnimatorListener() {
                            @Override
                            public void onAnimationStart(Animator animation) {

                            }

                            @Override
                            public void onAnimationEnd(Animator animation) {
                                swiping=false;
                                listView.setEnabled(true);
                            }

                            @Override
                            public void onAnimationCancel(Animator animation) {

                            }

                            @Override
                            public void onAnimationRepeat(Animator animation) {

                            }
                        });
                        firstAnimation=false;
                    }
                }
            }
            itemIdTopMap.clear();
            return true;
        }
    });
}

Надеюсь, это поможет вам, это хорошо работает для меня! Вы действительно должны смотреть девбайт, это мне очень помогло!

(Этот ответ относится к библиотеке Романа Нурики. Для библиотек, ответвленных от этого, он должен быть похожим).

Эта проблема возникает из-за того, что эти библиотеки хотят перерабатывать удаленные представления. В основном, после того, как элемент строки анимирован для исчезновения, библиотека сбрасывает его в исходное положение и выглядит так, чтобы listView мог его повторно использовать. Есть два обходных пути.

Решение 1

в performDismiss(...) метод библиотеки, найти часть кода, которая сбрасывает закрытое представление. Это часть:

ViewGroup.LayoutParams lp;
for (PendingDismissData pendingDismiss : mPendingDismisses) {
   // Reset view presentation
   pendingDismiss.view.setAlpha(1f);
   pendingDismiss.view.setTranslationX(0);
   lp = pendingDismiss.view.getLayoutParams();
   lp.height = originalHeight;
   pendingDismiss.view.setLayoutParams(lp);
}

mPendingDismisses.clear();

Удалите эту часть и поместите ее в отдельный public метод:

/**
 * Resets the deleted view objects to their 
 * original form, so that they can be reused by the
 * listview. This should be called after listview has 
 * the refreshed data available, e.g., in the onLoadFinished
 * method of LoaderManager.LoaderCallbacks interface.
 */
public void resetDeletedViews() {
    ViewGroup.LayoutParams lp;
    for (PendingDismissData pendingDismiss : mPendingDismisses) {
        // Reset view presentation
        pendingDismiss.view.setAlpha(1f);
        pendingDismiss.view.setTranslationX(0);
        lp = pendingDismiss.view.getLayoutParams();
        lp.height = originalHeight;
        pendingDismiss.view.setLayoutParams(lp);
    }

    mPendingDismisses.clear();
}

Наконец, в своей основной деятельности вызовите этот метод, когда новый курсор будет готов.

Решение 2

Забудьте об утилизации элемента строки (в конце концов, это всего лишь одна строка). Вместо того, чтобы сбросить вид и подготовить его к повторному использованию, пометьте его как окрашенный performDismiss(...) метод библиотеки.

Затем при заполнении вашего listView (переопределяя адаптер getView(View convertView, ...) метод), проверьте эту отметку на convertView объект. Если это там, не используйте convertView, Например, вы можете сделать (следующий фрагмент является псевдокодом)

if (convertView is marked as stained) {
   convertView = null;
}
return super.getView(convertView, ...);

Основываясь на ответе U Avalos, я реализовал обертку Cursor, которая обрабатывает несколько удаленных позиций. Однако решение еще не полностью протестировано и может содержать ошибки. Используйте это так, когда вы устанавливаете курсор

mAdapter.changeCursor(new CursorWithDelete(returnCursor));

Если вы хотите, чтобы скрыть какой-либо элемент из списка

CursorWithDelete cursor = (CursorWithDelete) mAdapter.getCursor();
cursor.deleteItem(position);
mAdapter.notifyDataSetChanged();

CusrsorWithDelete.java

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import android.database.AbstractCursor;
import android.database.Cursor;

public class CursorWithDelete extends AbstractCursor {
    private List<Integer> positionsToIgnore = new ArrayList<Integer>();
    private Cursor cursor;

    public CursorWithDelete(Cursor cursor) {
        this.cursor = cursor;
    }

    @Override
    public boolean onMove(int oldPosition, int newPosition) {
        cursor.moveToPosition(adjustPosition(newPosition));
        return true;
    }

    public int adjustPosition(int newPosition) {
        int ix = Collections.binarySearch(positionsToIgnore, newPosition);
        if (ix < 0) {
            ix = -ix - 1;
        } else {
            ix++;
        }
        int newPos;
        int lastRemovedPosition;
        do {
            newPos = newPosition + ix;
            lastRemovedPosition = positionsToIgnore.size() == ix ? -1 : positionsToIgnore.get(ix);
            ix++;
        } while (lastRemovedPosition >= 0 && newPos >= lastRemovedPosition);
        return newPos;
    }

    @Override
    public int getCount() {
        return cursor.getCount() - positionsToIgnore.size();
    }

    @Override
    public String[] getColumnNames() {
        return cursor.getColumnNames();
    }

    @Override
    public String getString(int column) {
        return cursor.getString(column);
    }

    @Override
    public short getShort(int column) {
        return cursor.getShort(column);
    }

    @Override
    public int getInt(int column) {
        return cursor.getInt(column);
    }

    @Override
    public long getLong(int column) {
        return cursor.getLong(column);
    }

    @Override
    public float getFloat(int column) {
        return cursor.getFloat(column);
    }

    @Override
    public double getDouble(int column) {
        return cursor.getDouble(column);
    }

    @Override
    public boolean isNull(int column) {
        return cursor.isNull(column);
    }

    /**
     * Call if you want to hide some position from the result
     * 
     * @param position in the AdapterView, not the cursor position
     */
    public void deleteItem(int position) {
        position = adjustPosition(position);
        int ix = Collections.binarySearch(positionsToIgnore, position);
        positionsToIgnore.add(-ix - 1, position);
    }
}
Другие вопросы по тегам