Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-23 08:51:57 +01:00
parent 61d62cabdf
commit ad3df69bdb
872 changed files with 65976 additions and 2 deletions

View file

@ -0,0 +1,15 @@
apply plugin: 'com.android.library'
android {
compileSdk 35
namespace 'it.niedermann.android.crosstabdnd'
defaultConfig {
minSdkVersion 22
targetSdk 35
}
}
dependencies {
implementation "com.google.android.material:material:$rootProject.materialVersion"
}

View file

@ -0,0 +1 @@
<manifest />

View file

@ -0,0 +1,228 @@
package it.niedermann.android.crosstabdnd;
import android.content.res.Resources;
import android.util.Log;
import android.view.DragEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class CrossTabDragAndDrop<
TabFragment extends Fragment & DragAndDropTab<ItemAdapter>,
ItemAdapter extends RecyclerView.Adapter<?> & DragAndDropAdapter<ItemModel>,
ItemModel extends DragAndDropModel> {
private static final String TAG = CrossTabDragAndDrop.class.getCanonicalName();
private static final ScrollHelper SCROLL_HELPER = new ScrollHelper();
private final boolean isLayoutLtr;
private final float pxToReact;
private final float pxToReactTopBottom;
private final int dragAndDropMsToReact;
private final int dragAndDropMsToReactTopBottom;
private final int displayX;
private long lastSwap = 0;
private long lastMove = 0;
private final Set<ItemMovedByDragListener<ItemModel>> moveListenerList = new HashSet<>(1);
public CrossTabDragAndDrop(@NonNull Resources resources, boolean isLayoutLtr) {
this.displayX = resources.getDisplayMetrics().widthPixels;
final float density = resources.getDisplayMetrics().density;
this.pxToReact = resources.getInteger(R.integer.drag_n_drop_dp_to_react) * density;
this.dragAndDropMsToReactTopBottom = resources.getInteger(R.integer.drag_n_drop_dp_to_react_top_bottom);
this.pxToReactTopBottom = dragAndDropMsToReactTopBottom * density;
this.dragAndDropMsToReact = resources.getInteger(R.integer.drag_n_drop_ms_to_react);
this.isLayoutLtr = isLayoutLtr;
}
public void register(final ViewPager2 viewPager, TabLayout stackLayout, FragmentManager fm) {
viewPager.setOnDragListener((View v, DragEvent dragEvent) -> {
//noinspection unchecked
final DraggedItemLocalState<TabFragment, ItemAdapter, ItemModel> draggedItemLocalState = (DraggedItemLocalState<TabFragment, ItemAdapter, ItemModel>) dragEvent.getLocalState();
// https://github.com/stefan-niedermann/nextcloud-deck/issues/1025
if (draggedItemLocalState == null) {
Log.v(TAG, "dragEvent has no localState → Cancelling DragListener.");
return false;
}
final View draggedView = draggedItemLocalState.getDraggedView();
switch (dragEvent.getAction()) {
case DragEvent.ACTION_DRAG_STARTED: {
draggedView.setVisibility(View.INVISIBLE);
draggedItemLocalState.onDragStart(viewPager, fm);
break;
}
case DragEvent.ACTION_DRAG_LOCATION: {
final RecyclerView currentRecyclerView = draggedItemLocalState.getRecyclerView();
final ItemAdapter itemAdapter = draggedItemLocalState.getItemAdapter();
final long now = System.currentTimeMillis();
if (lastSwap + dragAndDropMsToReact < now) { // don't change Tabs so fast!
final int oldTabPosition = viewPager.getCurrentItem();
boolean shouldSwitchTab = true;
int newTabPosition = -1;
// change tab? if yes, which direction?
if (dragEvent.getX() <= pxToReact) {
newTabPosition = isLayoutLtr ? oldTabPosition - 1 : oldTabPosition + 1;
} else if (dragEvent.getX() >= displayX - pxToReact) {
newTabPosition = isLayoutLtr ? oldTabPosition + 1 : oldTabPosition - 1;
} else {
shouldSwitchTab = false;
}
if (shouldSwitchTab && isMovePossible(viewPager, newTabPosition)) {
removeItem(currentRecyclerView, draggedView, itemAdapter);
detectAndKillDuplicatesInNeighbourTab(viewPager, draggedItemLocalState.getDraggedItemModel(), fm, oldTabPosition, newTabPosition);
switchTab(dragEvent, viewPager, stackLayout, fm, draggedItemLocalState, now, newTabPosition);
return true;
}
}
//scroll if needed
if (dragEvent.getY() <= pxToReactTopBottom) {
SCROLL_HELPER.startScroll(currentRecyclerView, ScrollHelper.ScrollDirection.UP);
} else if (dragEvent.getY() >= currentRecyclerView.getBottom() - pxToReactTopBottom) {
SCROLL_HELPER.startScroll(currentRecyclerView, ScrollHelper.ScrollDirection.DOWN);
} else {
SCROLL_HELPER.stopScroll();
}
if (lastMove + dragAndDropMsToReactTopBottom < now) {
//push around the other items
pushAroundItems(draggedView, currentRecyclerView, dragEvent, itemAdapter, draggedItemLocalState, now);
}
break;
}
case DragEvent.ACTION_DRAG_ENDED: {
draggedItemLocalState.getRecyclerView().removeOnChildAttachStateChangeListener(draggedItemLocalState.getInsertedListener());
SCROLL_HELPER.stopScroll();
draggedView.setVisibility(View.VISIBLE);
// Clean up the original dragged view, so the next onBindViewHolder() will not display the view at the position of the original dragged view as View.INVISIBLE
draggedItemLocalState.getOriginalDraggedView().setVisibility(View.VISIBLE);
notifyListeners(draggedItemLocalState);
break;
}
}
return true;
});
}
private void switchTab(DragEvent dragEvent, ViewPager2 viewPager, TabLayout stackLayout, FragmentManager fm, final DraggedItemLocalState<TabFragment, ItemAdapter, ItemModel> draggedItemLocalState, long now, int newPosition) {
viewPager.setCurrentItem(newPosition);
draggedItemLocalState.onTabChanged(viewPager, fm);
Objects.requireNonNull(stackLayout.getTabAt(newPosition)).select();
final RecyclerView recyclerView = draggedItemLocalState.getRecyclerView();
final ItemAdapter itemAdapter = draggedItemLocalState.getItemAdapter();
RecyclerView.OnChildAttachStateChangeListener onChildAttachStateChangeListener = new RecyclerView.OnChildAttachStateChangeListener() {
@Override
public void onChildViewAttachedToWindow(@NonNull View view) {
recyclerView.removeOnChildAttachStateChangeListener(this);
draggedItemLocalState.setInsertedListener(null);
view.setVisibility(View.INVISIBLE);
draggedItemLocalState.setDraggedView(view);
pushAroundItems(view, recyclerView, dragEvent, itemAdapter, draggedItemLocalState, now);
}
@Override
public void onChildViewDetachedFromWindow(@NonNull View view) {/* do nothing */}
};
draggedItemLocalState.setInsertedListener(onChildAttachStateChangeListener);
recyclerView.addOnChildAttachStateChangeListener(onChildAttachStateChangeListener);
//insert item in new tab
@Nullable final View firstVisibleView = recyclerView.getChildAt(0);
int positionToInsert = firstVisibleView == null ? 0 : recyclerView.getChildAdapterPosition(firstVisibleView) + 1;
itemAdapter.insertItem(draggedItemLocalState.getDraggedItemModel(), positionToInsert);
lastSwap = now;
}
private void pushAroundItems(@NonNull View view, RecyclerView recyclerView, DragEvent dragEvent, ItemAdapter itemAdapter, DraggedItemLocalState<TabFragment, ItemAdapter, ItemModel> draggedItemLocalState, long now) {
@Nullable final View viewUnder = recyclerView.findChildViewUnder(dragEvent.getX(), dragEvent.getY());
if (viewUnder != null) {
final int toPositon = recyclerView.getChildAdapterPosition(viewUnder);
if (toPositon != -1) {
final int fromPosition = recyclerView.getChildAdapterPosition(view);
if (fromPosition != -1 && fromPosition != toPositon) {
recyclerView.post(() -> {
itemAdapter.moveItem(fromPosition, toPositon);
draggedItemLocalState.setPositionInItemAdapter(toPositon);
});
lastMove = now;
}
}
}
}
private static boolean isMovePossible(ViewPager2 viewPager, int newPosition) {
return newPosition < Objects.requireNonNull(viewPager.getAdapter()).getItemCount() && newPosition >= 0;
}
private void detectAndKillDuplicatesInNeighbourTab(ViewPager2 viewPager, ItemModel itemToFind, FragmentManager fm, int oldTabPosition, int newTabPosition) {
final int tabPositionToCheck = newTabPosition > oldTabPosition ? newTabPosition + 1 : newTabPosition - 1;
if (isMovePossible(viewPager, tabPositionToCheck)) {
viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
viewPager.unregisterOnPageChangeCallback(this);
final ItemAdapter itemAdapter = DragAndDropUtil.<TabFragment>getTabFragment(fm, Objects.requireNonNull(viewPager.getAdapter()).getItemId(tabPositionToCheck)).getAdapter();
itemAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
@Override
public void onChanged() {
super.onChanged();
itemAdapter.unregisterAdapterDataObserver(this);
final List<ItemModel> itemList = itemAdapter.getItemList();
for (int i = 0; i < itemList.size(); i++) {
final ItemModel c = itemList.get(i);
if (itemToFind.getComparableId().equals(c.getComparableId())) {
itemAdapter.removeItem(i);
itemAdapter.notifyItemRemoved(i);
Log.v(TAG, "DnD removed dupe at tab " + tabPositionToCheck + ": " + c.toString());
break;
}
}
}
});
}
});
}
}
private void removeItem(RecyclerView currentRecyclerView, View view, ItemAdapter itemAdapter) {
final int oldItemPosition = currentRecyclerView.getChildAdapterPosition(view);
if (oldItemPosition != -1) {
itemAdapter.removeItem(oldItemPosition);
}
}
private void notifyListeners(DraggedItemLocalState<TabFragment, ItemAdapter, ItemModel> draggedItemLocalState) {
for (ItemMovedByDragListener<ItemModel> listener : moveListenerList) {
listener.onItemMoved(draggedItemLocalState.getDraggedItemModel(), draggedItemLocalState.getCurrentTabId(), draggedItemLocalState.getPositionInItemAdapter());
}
}
public void addItemMovedByDragListener(ItemMovedByDragListener<ItemModel> listener) {
moveListenerList.add(listener);
}
}

View file

@ -0,0 +1,17 @@
package it.niedermann.android.crosstabdnd;
import androidx.annotation.NonNull;
import java.util.List;
public interface DragAndDropAdapter<Model> {
void removeItem(int position);
void moveItem(int fromPosition, int toPosition);
void insertItem(Model item, int position);
@NonNull
List<Model> getItemList();
}

View file

@ -0,0 +1,8 @@
package it.niedermann.android.crosstabdnd;
import androidx.annotation.NonNull;
public interface DragAndDropModel {
@NonNull
Long getComparableId();
}

View file

@ -0,0 +1,12 @@
package it.niedermann.android.crosstabdnd;
import androidx.recyclerview.widget.RecyclerView;
public interface DragAndDropTab<ItemAdapter extends RecyclerView.Adapter<?> & DragAndDropAdapter<?>> {
ItemAdapter getAdapter();
RecyclerView getRecyclerView();
}

View file

@ -0,0 +1,19 @@
package it.niedermann.android.crosstabdnd;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentManager;
// Public util
@SuppressWarnings("WeakerAccess")
public class DragAndDropUtil {
private DragAndDropUtil() {
// Util class
}
@SuppressWarnings("unchecked")
protected static <T> T getTabFragment(@NonNull FragmentManager fm, @Nullable Long currentStackId) throws IllegalArgumentException {
return (T) fm.findFragmentByTag("f" + currentStackId);
}
}

View file

@ -0,0 +1,97 @@
package it.niedermann.android.crosstabdnd;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import java.util.Objects;
@SuppressWarnings("WeakerAccess")
public class DraggedItemLocalState<
TabFragment extends Fragment & DragAndDropTab<ItemAdapter>,
ItemAdapter extends RecyclerView.Adapter<?> & DragAndDropAdapter<ItemModel>,
ItemModel> {
private final ItemModel draggedCard;
/** The original dragged view */
private final View originalDraggedView;
/** The currently dragged view (can change when the tab changes */
private View draggedView;
private ItemAdapter itemAdapter;
private int positionInCardAdapter;
private RecyclerView.OnChildAttachStateChangeListener insertedListener = null;
private RecyclerView recyclerView = null;
private long currentTabId;
public DraggedItemLocalState(ItemModel draggedCard, View draggedView, ItemAdapter itemAdapter, int positionInCardAdapter) {
this.draggedCard = draggedCard;
this.draggedView = draggedView;
this.originalDraggedView = draggedView;
this.itemAdapter = itemAdapter;
this.positionInCardAdapter = positionInCardAdapter;
}
protected void onDragStart(@NonNull ViewPager2 viewPager, @NonNull FragmentManager fm) {
this.currentTabId = Objects.requireNonNull(viewPager.getAdapter()).getItemId(viewPager.getCurrentItem());
this.recyclerView = DragAndDropUtil.<TabFragment>getTabFragment(fm, currentTabId).getRecyclerView();
}
protected void onTabChanged(@NonNull ViewPager2 viewPager, @NonNull FragmentManager fm) {
if (insertedListener != null) {
this.recyclerView.removeOnChildAttachStateChangeListener(insertedListener);
this.insertedListener = null;
}
this.currentTabId = Objects.requireNonNull(viewPager.getAdapter()).getItemId(viewPager.getCurrentItem());
this.recyclerView = DragAndDropUtil.<TabFragment>getTabFragment(fm, currentTabId).getRecyclerView();
//noinspection unchecked
this.itemAdapter = (ItemAdapter) recyclerView.getAdapter();
}
protected long getCurrentTabId() {
return currentTabId;
}
protected ItemModel getDraggedItemModel() {
return draggedCard;
}
protected View getOriginalDraggedView() {
return originalDraggedView;
}
protected View getDraggedView() {
return draggedView;
}
protected void setDraggedView(View draggedView) {
this.draggedView = draggedView;
}
protected ItemAdapter getItemAdapter() {
return itemAdapter;
}
protected int getPositionInItemAdapter() {
return positionInCardAdapter;
}
protected void setPositionInItemAdapter(int positionInCardAdapter) {
this.positionInCardAdapter = positionInCardAdapter;
}
protected void setInsertedListener(RecyclerView.OnChildAttachStateChangeListener insertedListener) {
this.insertedListener = insertedListener;
}
public RecyclerView.OnChildAttachStateChangeListener getInsertedListener() {
return insertedListener;
}
protected RecyclerView getRecyclerView() {
return recyclerView;
}
}

View file

@ -0,0 +1,5 @@
package it.niedermann.android.crosstabdnd;
public interface ItemMovedByDragListener<ItemModel> {
void onItemMoved(ItemModel movedItem, long tabId, int position);
}

View file

@ -0,0 +1,48 @@
package it.niedermann.android.crosstabdnd;
import android.os.Handler;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
@SuppressWarnings("WeakerAccess")
public class ScrollHelper implements Runnable {
private static final int SCROLL_SPEED = 200;
public enum ScrollDirection {
UP,
DOWN
}
private boolean shouldScroll = false;
private ScrollDirection scrollDirection;
private RecyclerView currentRecyclerView;
private final Handler handler = new Handler();
public void startScroll(@NonNull RecyclerView recyclerView, @Nullable ScrollDirection scrollDirection) {
this.scrollDirection = scrollDirection;
this.currentRecyclerView = recyclerView;
if (!shouldScroll) {
this.shouldScroll = true;
handler.post(this);
}
}
public void stopScroll() {
this.shouldScroll = false;
}
@Override
public void run() {
if (scrollDirection == ScrollDirection.UP) {
currentRecyclerView.smoothScrollBy(0, SCROLL_SPEED * -1);
} else {
currentRecyclerView.smoothScrollBy(0, SCROLL_SPEED);
}
if (shouldScroll) {
handler.postDelayed(this, 100);
}
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- How many microseconds should be waited before switching to another tab when dragging cards -->
<integer name="drag_n_drop_ms_to_react">500</integer>
<!-- How many dp from border should one be away before switching to another tab when dragging cards -->
<integer name="drag_n_drop_dp_to_react">30</integer>
<!-- How many dp from top / bottom border should one be away before scrolling top / down when dragging cards -->
<integer name="drag_n_drop_dp_to_react_top_bottom">100</integer>
</resources>