From d5ba61178205f8770901454d9a1dc0ffef4a7987 Mon Sep 17 00:00:00 2001 From: Martijn Brekhof Date: Mon, 11 Jan 2016 10:32:07 +0100 Subject: [PATCH] Implemented changing the position of items in the current playlist This enables users to reorder the current playlist by long pressing a list item and drag it to a different list position. --- .../xbmc/kore/jsonrpc/method/Playlist.java | 23 + .../org/xbmc/kore/ui/PlaylistFragment.java | 257 +++---- .../kore/ui/viewgroups/DynamicListView.java | 667 ++++++++++++++++++ app/src/main/res/layout/fragment_playlist.xml | 10 +- app/src/main/res/values/strings.xml | 2 + 5 files changed, 834 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/org/xbmc/kore/ui/viewgroups/DynamicListView.java diff --git a/app/src/main/java/org/xbmc/kore/jsonrpc/method/Playlist.java b/app/src/main/java/org/xbmc/kore/jsonrpc/method/Playlist.java index da7069b..c26d275 100644 --- a/app/src/main/java/org/xbmc/kore/jsonrpc/method/Playlist.java +++ b/app/src/main/java/org/xbmc/kore/jsonrpc/method/Playlist.java @@ -170,4 +170,27 @@ public class Playlist { return jsonObject.get(RESULT_NODE).textValue(); } } + + + public static final class Insert extends ApiMethod { + public final static String METHOD_NAME = "Playlist.Insert"; + + /** + * Add item(s) to playlist + */ + public Insert(int playlistId, int position, PlaylistType.Item item) { + super(); + addParameterToRequest("playlistid", playlistId); + addParameterToRequest("position", position); + addParameterToRequest("item", item); + } + + @Override + public String getMethodName() { return METHOD_NAME; } + + @Override + public String resultFromJson(ObjectNode jsonObject) throws ApiException { + return jsonObject.get(RESULT_NODE).textValue(); + } + } } diff --git a/app/src/main/java/org/xbmc/kore/ui/PlaylistFragment.java b/app/src/main/java/org/xbmc/kore/ui/PlaylistFragment.java index b47497a..4c9ce45 100644 --- a/app/src/main/java/org/xbmc/kore/ui/PlaylistFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/PlaylistFragment.java @@ -30,12 +30,11 @@ import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.BaseAdapter; -import android.widget.GridView; import android.widget.ImageView; -import android.widget.ListAdapter; import android.widget.PopupMenu; import android.widget.RelativeLayout; import android.widget.TextView; +import android.widget.Toast; import org.xbmc.kore.R; import org.xbmc.kore.host.HostConnectionObserver; @@ -44,16 +43,17 @@ import org.xbmc.kore.host.HostInfo; import org.xbmc.kore.host.HostManager; import org.xbmc.kore.jsonrpc.ApiCallback; import org.xbmc.kore.jsonrpc.ApiMethod; +import org.xbmc.kore.jsonrpc.HostConnection; import org.xbmc.kore.jsonrpc.method.Player; import org.xbmc.kore.jsonrpc.method.Playlist; import org.xbmc.kore.jsonrpc.type.ListType; import org.xbmc.kore.jsonrpc.type.PlayerType; import org.xbmc.kore.jsonrpc.type.PlaylistType; +import org.xbmc.kore.ui.viewgroups.DynamicListView; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.UIUtils; import org.xbmc.kore.utils.Utils; -import java.util.ArrayList; import java.util.List; import butterknife.ButterKnife; @@ -100,18 +100,11 @@ public class PlaylistFragment extends Fragment * Injectable views */ @InjectView(R.id.info_panel) RelativeLayout infoPanel; - @InjectView(R.id.playlist) GridView playlistGridView; + @InjectView(R.id.playlist) DynamicListView playlistListView; @InjectView(R.id.info_title) TextView infoTitle; @InjectView(R.id.info_message) TextView infoMessage; -// @InjectView(R.id.play) ImageButton playButton; -// @InjectView(R.id.stop) ImageButton stopButton; -// @InjectView(R.id.previous) ImageButton previousButton; -// @InjectView(R.id.next) ImageButton nextButton; -// @InjectView(R.id.rewind) ImageButton rewindButton; -// @InjectView(R.id.fast_forward) ImageButton fastForwardButton; - @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -125,10 +118,10 @@ public class PlaylistFragment extends Fragment ButterKnife.inject(this, root); playListAdapter = new PlayListAdapter(); - playlistGridView.setAdapter(playListAdapter); + playlistListView.setAdapter(playListAdapter); // When clicking on an item, play it - playlistGridView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + playlistListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { Player.Open action = new Player.Open(Player.Open.TYPE_PLAYLIST, currentPlaylistId, position); @@ -136,10 +129,6 @@ public class PlaylistFragment extends Fragment } }); -// // Pad main content view to overlap bottom system bar -// UIUtils.setPaddingForSystemBars(getActivity(), playlistGridView, false, false, true); -// playlistGridView.setClipToPadding(false); - return root; } @@ -184,28 +173,6 @@ public class PlaylistFragment extends Fragment return super.onOptionsItemSelected(item); } -// @Override -// public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { -// super.onCreateContextMenu(menu, v, menuInfo); -// // Add the options -// menu.add(0, CONTEXT_MENU_REMOVE_ITEM, 1, R.string.remove); -// } -// -// @Override -// public boolean onContextItemSelected(android.view.MenuItem item) { -// AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo(); -// -// switch (item.getItemId()) { -// case CONTEXT_MENU_REMOVE_ITEM: -// // Remove this item from the playlist -// Playlist.Remove action = new Playlist.Remove(currentPlaylistId, info.position); -// action.execute(hostManager.getConnection(), defaultStringActionCallback, callbackHandler); -// forceRefreshPlaylist(); -// return true; -// } -// return super.onContextItemSelected(item); -// } - public void forceRefreshPlaylist() { // If we are playing something, refresh playlist if ((lastCallResult == PLAYER_IS_PLAYING) || (lastCallResult == PLAYER_IS_PAUSED)) { @@ -218,59 +185,6 @@ public class PlaylistFragment extends Fragment */ private ApiCallback defaultStringActionCallback = ApiMethod.getDefaultActionCallback(); -// /** -// * Callback for methods that change the play speed -// */ -// private ApiCallback defaultPlaySpeedChangedCallback = new ApiCallback() { -// @Override -// public void onSuccess(Integer result) { -// UIUtils.setPlayPauseButtonIcon(getActivity(), playButton, result); -// } -// -// @Override -// public void onError(int errorCode, String description) { } -// }; -// -// /** -// * Callbacks for bottom button bar -// */ -// @OnClick(R.id.play) -// public void onPlayClicked(View v) { -// Player.PlayPause action = new Player.PlayPause(currentActivePlayerId); -// action.execute(hostManager.getConnection(), defaultPlaySpeedChangedCallback, callbackHandler); -// } -// -// @OnClick(R.id.stop) -// public void onStopClicked(View v) { -// Player.Stop action = new Player.Stop(currentActivePlayerId); -// action.execute(hostManager.getConnection(), defaultStringActionCallback, callbackHandler); -// UIUtils.setPlayPauseButtonIcon(getActivity(), playButton, 0); -// } -// -// @OnClick(R.id.fast_forward) -// public void onFastForwardClicked(View v) { -// Player.SetSpeed action = new Player.SetSpeed(currentActivePlayerId, GlobalType.IncrementDecrement.INCREMENT); -// action.execute(hostManager.getConnection(), defaultPlaySpeedChangedCallback, callbackHandler); -// } -// -// @OnClick(R.id.rewind) -// public void onRewindClicked(View v) { -// Player.SetSpeed action = new Player.SetSpeed(currentActivePlayerId, GlobalType.IncrementDecrement.DECREMENT); -// action.execute(hostManager.getConnection(), defaultPlaySpeedChangedCallback, callbackHandler); -// } -// -// @OnClick(R.id.previous) -// public void onPreviousClicked(View v) { -// Player.GoTo action = new Player.GoTo(currentActivePlayerId, Player.GoTo.PREVIOUS); -// action.execute(hostManager.getConnection(), defaultStringActionCallback, callbackHandler); -// } -// -// @OnClick(R.id.next) -// public void onNextClicked(View v) { -// Player.GoTo action = new Player.GoTo(currentActivePlayerId, Player.GoTo.NEXT); -// action.execute(hostManager.getConnection(), defaultStringActionCallback, callbackHandler); -// } - /** * Last call results */ @@ -287,9 +201,9 @@ public class PlaylistFragment extends Fragment PlayerType.PropertyValue getPropertiesResult, ListType.ItemsAll getItemResult) { if ((lastGetPlaylistItemsResult == null) || - (lastCallResult != PlayerEventsObserver.PLAYER_IS_PLAYING) || - (currentActivePlayerId != getActivePlayerResult.playerid) || - (lastGetItemResult.id != getItemResult.id)) { + (lastCallResult != PlayerEventsObserver.PLAYER_IS_PLAYING) || + (currentActivePlayerId != getActivePlayerResult.playerid) || + (lastGetItemResult.id != getItemResult.id)) { // Check if something is different, and only if so, start the chain calls setupPlaylistInfo(getActivePlayerResult, getPropertiesResult, getItemResult); currentActivePlayerId = getActivePlayerResult.playerid; @@ -297,8 +211,6 @@ public class PlaylistFragment extends Fragment // Hopefully nothing changed, so just use the last results displayPlaylist(getItemResult, lastGetPlaylistItemsResult); } - // Switch icon -// UIUtils.setPlayPauseButtonIcon(getActivity(), playButton, getPropertiesResult.speed); // Save results lastCallResult = PLAYER_IS_PLAYING; @@ -311,17 +223,15 @@ public class PlaylistFragment extends Fragment PlayerType.PropertyValue getPropertiesResult, ListType.ItemsAll getItemResult) { if ((lastGetPlaylistItemsResult == null) || - (lastCallResult != PlayerEventsObserver.PLAYER_IS_PLAYING) || - (currentActivePlayerId != getActivePlayerResult.playerid) || - (lastGetItemResult.id != getItemResult.id)) { + (lastCallResult != PlayerEventsObserver.PLAYER_IS_PLAYING) || + (currentActivePlayerId != getActivePlayerResult.playerid) || + (lastGetItemResult.id != getItemResult.id)) { setupPlaylistInfo(getActivePlayerResult, getPropertiesResult, getItemResult); currentActivePlayerId = getActivePlayerResult.playerid; } else { // Hopefully nothing changed, so just use the last results displayPlaylist(getItemResult, lastGetPlaylistItemsResult); } - // Switch icon -// UIUtils.setPlayPauseButtonIcon(getActivity(), playButton, getPropertiesResult.speed); lastCallResult = PLAYER_IS_PAUSED; lastGetActivePlayerResult = getActivePlayerResult; @@ -436,18 +346,34 @@ public class PlaylistFragment extends Fragment } switchToPanel(R.id.playlist); - // Set items, which call notifyDataSetChanged - playListAdapter.setPlaylistItems(playlistItems); - // Present the checked item + //If a user is dragging a list item we must not modify the adapter to prevent + //the dragged item's adapter position from diverging from its listview position + if (!playlistListView.isItemBeingDragged()) { + // Set items, which call notifyDataSetChanged + playListAdapter.setPlaylistItems(playlistItems); + highlightItem(getItemResult, playlistItems); + } else { + highlightItem(getItemResult, playListAdapter.playlistItems); + } + } + + private void highlightItem(final ListType.ItemsAll item, + final List playlistItems) { for (int i = 0; i < playlistItems.size(); i++) { - if ((playlistItems.get(i).id == getItemResult.id) && - (playlistItems.get(i).type.equals(getItemResult.type))) { - playlistGridView.setItemChecked(i, true); - playlistGridView.setSelection(i); + if ((playlistItems.get(i).id == item.id) && + (playlistItems.get(i).type.equals(item.type))) { + + //When user is dragging an item it is very annoying when we change the list position + if (!playlistListView.isItemBeingDragged()) { + playlistListView.setSelection(i); + } + playlistListView.setItemChecked(i, true); + } } } + /** * Switches the info panel shown (they are exclusive) * @param panelResId The panel to show @@ -456,11 +382,11 @@ public class PlaylistFragment extends Fragment switch (panelResId) { case R.id.info_panel: infoPanel.setVisibility(View.VISIBLE); - playlistGridView.setVisibility(View.GONE); + playlistListView.setVisibility(View.GONE); break; case R.id.playlist: infoPanel.setVisibility(View.GONE); - playlistGridView.setVisibility(View.VISIBLE); + playlistListView.setVisibility(View.VISIBLE); break; } } @@ -488,7 +414,7 @@ public class PlaylistFragment extends Fragment * Adapter used to show the hosts in the ListView */ private class PlayListAdapter extends BaseAdapter - implements ListAdapter { + implements DynamicListView.DynamicListAdapter { private View.OnClickListener playlistItemMenuClickListener = new View.OnClickListener() { @Override public void onClick(View v) { @@ -532,9 +458,9 @@ public class PlaylistFragment extends Fragment R.attr.appCardBackgroundColor, R.attr.appSelectedCardBackgroundColor}); cardBackgroundColor = styledAttributes.getColor(0, - getResources().getColor(R.color.dark_content_background)); + getResources().getColor(R.color.dark_content_background)); selectedCardBackgroundColor = styledAttributes.getColor(1, - getResources().getColor(R.color.dark_selected_content_background)); + getResources().getColor(R.color.dark_selected_content_background)); styledAttributes.recycle(); } @@ -573,7 +499,10 @@ public class PlaylistFragment extends Fragment @Override public long getItemId(int position) { - return position; + if (position < 0 || position >= playlistItems.size()) { + return -1; + } + return playlistItems.get(position).id; } @Override @@ -581,13 +510,67 @@ public class PlaylistFragment extends Fragment return 1; } + @Override + public void onSwapItems(int positionOne, int positionTwo) { + ListType.ItemsAll tmp = playlistItems.get(positionOne); + playlistItems.set(positionOne, playlistItems.get(positionTwo)); + playlistItems.set(positionTwo, tmp); + } + + @Override + public void onSwapFinished(final int originalPosition, final int finalPosition) { + final HostConnection hostConnection = hostManager.getConnection(); + + if (playlistItems.get(finalPosition).id == lastGetItemResult.id) { + Toast.makeText(getActivity(), R.string.cannot_move_playing_item, Toast.LENGTH_SHORT) + .show(); + rollbackSwappedItems(originalPosition, finalPosition); + notifyDataSetChanged(); + return; + } + + Playlist.Remove remove = new Playlist.Remove(currentPlaylistId, originalPosition); + remove.execute(hostConnection, new ApiCallback() { + @Override + public void onSuccess(String result) { + Playlist.Insert insert = new Playlist.Insert(currentPlaylistId, finalPosition, createPlaylistTypeItem(playlistItems.get(finalPosition))); + insert.execute(hostConnection, new ApiCallback() { + @Override + public void onSuccess(String result) { + } + + @Override + public void onError(int errorCode, String description) { + //Remove succeeded but insert failed, so we need to remove item from playlist at final position + playlistItems.remove(finalPosition); + notifyDataSetChanged(); + if (!isAdded()) return; + // Got an error, show toast + Toast.makeText(getActivity(), R.string.unable_to_move_item, Toast.LENGTH_SHORT) + .show(); + } + }, callbackHandler); + } + + @Override + public void onError(int errorCode, String description) { + rollbackSwappedItems(originalPosition, finalPosition); + notifyDataSetChanged(); + if (!isAdded()) return; + // Got an error, show toast + Toast.makeText(getActivity(), R.string.unable_to_move_item, Toast.LENGTH_SHORT) + .show(); + } + }, callbackHandler); + } + @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder viewHolder; if (convertView == null) { convertView = LayoutInflater.from(getActivity()) - .inflate(R.layout.grid_item_playlist, parent, false); + .inflate(R.layout.grid_item_playlist, parent, false); // ViewHolder pattern viewHolder = new ViewHolder(); viewHolder.art = (ImageView)convertView.findViewById(R.id.art); @@ -647,13 +630,13 @@ public class PlaylistFragment extends Fragment viewHolder.duration.setText((duration > 0) ? UIUtils.formatTime(duration) : ""); viewHolder.position = position; - int cardColor = (position == playlistGridView.getCheckedItemPosition()) ? - selectedCardBackgroundColor: cardBackgroundColor; + int cardColor = (position == playlistListView.getCheckedItemPosition()) ? + selectedCardBackgroundColor: cardBackgroundColor; viewHolder.card.setCardBackgroundColor(cardColor); // If not video, change aspect ration of poster to a square boolean isVideo = (item.type.equals(ListType.ItemsAll.TYPE_MOVIE)) || - (item.type.equals(ListType.ItemsAll.TYPE_EPISODE)); + (item.type.equals(ListType.ItemsAll.TYPE_EPISODE)); if (!isVideo) { ViewGroup.LayoutParams layoutParams = viewHolder.art.getLayoutParams(); layoutParams.width = layoutParams.height; @@ -671,6 +654,42 @@ public class PlaylistFragment extends Fragment return convertView; } + private PlaylistType.Item createPlaylistTypeItem(ListType.ItemsAll item) { + PlaylistType.Item playlistItem = new PlaylistType.Item(); + + switch (item.type) { + case ListType.ItemsAll.TYPE_MOVIE: + playlistItem.movieid = item.id; + break; + case ListType.ItemsAll.TYPE_EPISODE: + playlistItem.episodeid = item.id; + break; + case ListType.ItemsAll.TYPE_SONG: + playlistItem.songid = item.id; + break; + case ListType.ItemsAll.TYPE_MUSIC_VIDEO: + playlistItem.musicvideoid = item.id; + break; + default: + LogUtils.LOGE(TAG, "createPlaylistTypeItem, failed to create item for "+item.type); + break; + } + + return playlistItem; + } + + private void rollbackSwappedItems(int originalPosition, int newPosition) { + if (originalPosition > newPosition) { + for (int i = newPosition; i < originalPosition; i++) { + onSwapItems(i, i + 1); + } + } else if (originalPosition < newPosition) { + for (int i = newPosition; i > originalPosition; i--) { + onSwapItems(i, i - 1); + } + } + } + private class ViewHolder { ImageView art; TextView title; diff --git a/app/src/main/java/org/xbmc/kore/ui/viewgroups/DynamicListView.java b/app/src/main/java/org/xbmc/kore/ui/viewgroups/DynamicListView.java new file mode 100644 index 0000000..c9dff0a --- /dev/null +++ b/app/src/main/java/org/xbmc/kore/ui/viewgroups/DynamicListView.java @@ -0,0 +1,667 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * 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. + */ + +package org.xbmc.kore.ui.viewgroups; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.TypeEvaluator; +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.SparseBooleanArray; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ListAdapter; +import android.widget.ListView; + +import org.xbmc.kore.utils.LogUtils; + +/** + * The dynamic listview is an extension of listview that supports cell dragging + * and swapping. + * + * This layout is in charge of positioning the hover cell in the correct location + * on the screen in response to user touch events. It uses the position of the + * hover cell to determine when two cells should be swapped. If two cells should + * be swapped, all the corresponding data set and layout changes are handled here. + * + * If no cell is selected, all the touch events are passed down to the listview + * and behave normally. If one of the items in the listview experiences a + * long press event, the contents of its current visible state are captured as + * a bitmap and its visibility is set to INVISIBLE. A hover cell is then created and + * added to this layout as an overlaying BitmapDrawable above the listview. Once the + * hover cell is translated some distance to signify an item onSwapItems, a data set change + * accompanied by animation takes place. When the user releases the hover cell, + * it animates into its corresponding position in the listview. + * + * When the hover cell is either above or below the bounds of the listview, this + * listview also scrolls on its own so as to reveal additional content. + */ +public class DynamicListView extends ListView { + private static final String TAG = LogUtils.makeLogTag(DynamicListView.class); + + private final int SMOOTH_SCROLL_AMOUNT_AT_EDGE = 15; + private final int MOVE_DURATION = 150; + private final int LINE_THICKNESS = 15; + + private int mLastEventY = -1; + + private int mDownY = -1; + private int mDownX = -1; + + private int mTotalOffset = 0; + + private boolean mCellIsMobile = false; + private boolean mIsMobileScrolling = false; + private int mSmoothScrollAmountAtEdge = 0; + + private final int INVALID_ID = -1; + private long mAboveItemId = INVALID_ID; + private long mMobileItemId = INVALID_ID; + private long mBelowItemId = INVALID_ID; + + private BitmapDrawable mHoverCell; + private Rect mHoverCellCurrentBounds; + private Rect mHoverCellOriginalBounds; + + private int mOriginalPosition; + + private final int INVALID_POINTER_ID = -1; + private int mActivePointerId = INVALID_POINTER_ID; + + private boolean mIsWaitingForScrollFinish = false; + private int mScrollState = OnScrollListener.SCROLL_STATE_IDLE; + + private boolean itemBeingDragged; + + public interface DynamicListAdapter extends ListAdapter { + + /** + * Called when two items in the view need to be swapped. + * @param positionOne position of first item + * @param positionTwo position of second item. + */ + public void onSwapItems(int positionOne, int positionTwo); + + /** + * Called when the user releases the dragged item + * @param originalPostion original position of the dragged item + * @param finalPosition new position of the dragged item + */ + public void onSwapFinished(int originalPostion, int finalPosition); + } + + public DynamicListView(Context context) { + super(context); + init(context); + } + + public DynamicListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context); + } + + public DynamicListView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public void init(Context context) { + setOnItemLongClickListener(mOnItemLongClickListener); + setOnScrollListener(mScrollListener); + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + mSmoothScrollAmountAtEdge = (int)(SMOOTH_SCROLL_AMOUNT_AT_EDGE / metrics.density); + } + + @Override + public void setAdapter(ListAdapter adapter) { + throw new ClassCastException(adapter.toString() + " must implement DynamicsListAdapter"); + } + + public void setAdapter(DynamicListAdapter adapter) { + super.setAdapter(adapter); + } + + /** + * Use this to determine if an item is being repositioned in the list. + * The data in the adapter must not be updated other then through the + * callbacks {@link org.xbmc.kore.ui.viewgroups.DynamicListView.DynamicListAdapter#onSwapItems(int, int)} + * and {@link org.xbmc.kore.ui.viewgroups.DynamicListView.DynamicListAdapter#onSwapFinished(int, int)} + * + * @return true if item is selected, false otherwise + */ + public boolean isItemBeingDragged() { + return itemBeingDragged; + } + + /** + * Listens for long clicks on any items in the listview. When a cell has + * been selected, the hover cell is created and set up. + */ + private AdapterView.OnItemLongClickListener mOnItemLongClickListener = + new AdapterView.OnItemLongClickListener() { + public boolean onItemLongClick(AdapterView arg0, View arg1, int position, long id) { + mTotalOffset = 0; + + mOriginalPosition = position; + + int itemNum = position - getFirstVisiblePosition(); + + View selectedView = getChildAt(itemNum); + mMobileItemId = getAdapter().getItemId(position); + mHoverCell = getAndAddHoverView(selectedView); + selectedView.setVisibility(INVISIBLE); + + mCellIsMobile = true; + + itemBeingDragged = true; + + updateNeighborViewsForID(mMobileItemId); + return true; + } + }; + + /** + * Creates the hover cell with the appropriate bitmap and of appropriate + * size. The hover cell's BitmapDrawable is drawn on top of the bitmap every + * single time an invalidate call is made. + */ + private BitmapDrawable getAndAddHoverView(View v) { + + int w = v.getWidth(); + int h = v.getHeight(); + int top = v.getTop(); + int left = v.getLeft(); + + Bitmap b = getBitmapWithBorder(v); + + BitmapDrawable drawable = new BitmapDrawable(getResources(), b); + + mHoverCellOriginalBounds = new Rect(left, top, left + w, top + h); + mHoverCellCurrentBounds = new Rect(mHoverCellOriginalBounds); + + drawable.setBounds(mHoverCellCurrentBounds); + + return drawable; + } + + /** Draws a black border over the screenshot of the view passed in. */ + private Bitmap getBitmapWithBorder(View v) { + Bitmap bitmap = getBitmapFromView(v); + Canvas can = new Canvas(bitmap); + + Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + + Paint paint = new Paint(); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(LINE_THICKNESS); + paint.setColor(Color.BLACK); + + can.drawBitmap(bitmap, 0, 0, null); + can.drawRect(rect, paint); + + return bitmap; + } + + /** Returns a bitmap showing a screenshot of the view passed in. */ + private Bitmap getBitmapFromView(View v) { + Bitmap bitmap = Bitmap.createBitmap(v.getWidth(), v.getHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + v.draw(canvas); + return bitmap; + } + + /** + * Stores a reference to the views above and below the item currently + * corresponding to the hover cell. It is important to note that if this + * item is either at the top or bottom of the list, mAboveItemId or mBelowItemId + * may be invalid. + */ + private void updateNeighborViewsForID(long itemID) { + int position = getPositionForID(itemID); + ListAdapter adapter = getAdapter(); + mAboveItemId = adapter.getItemId(position - 1); + mBelowItemId = adapter.getItemId(position + 1); + } + + /** Retrieves the view in the list corresponding to itemID */ + public View getViewForID (long itemID) { + int firstVisiblePosition = getFirstVisiblePosition(); + ListAdapter adapter = getAdapter(); + for(int i = 0; i < getChildCount(); i++) { + View v = getChildAt(i); + int position = firstVisiblePosition + i; + long id = adapter.getItemId(position); + if (id == itemID) { + return v; + } + } + return null; + } + + /** Retrieves the position in the list corresponding to itemID */ + public int getPositionForID (long itemID) { + View v = getViewForID(itemID); + if (v == null) { + return -1; + } else { + return getPositionForView(v); + } + } + + /** + * dispatchDraw gets invoked when all the child views are about to be drawn. + * By overriding this method, the hover cell (BitmapDrawable) can be drawn + * over the listview's items whenever the listview is redrawn. + */ + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + if (mHoverCell != null) { + mHoverCell.draw(canvas); + } + } + + @Override + public boolean onTouchEvent (MotionEvent event) { + + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + mDownX = (int)event.getX(); + mDownY = (int)event.getY(); + mActivePointerId = event.getPointerId(0); + break; + case MotionEvent.ACTION_MOVE: + if (mActivePointerId == INVALID_POINTER_ID) { + break; + } + + int pointerIndex = event.findPointerIndex(mActivePointerId); + + mLastEventY = (int) event.getY(pointerIndex); + int deltaY = mLastEventY - mDownY; + + if (mCellIsMobile) { + mHoverCellCurrentBounds.offsetTo(mHoverCellOriginalBounds.left, + mHoverCellOriginalBounds.top + deltaY + mTotalOffset); + mHoverCell.setBounds(mHoverCellCurrentBounds); + invalidate(); + + handleCellSwitch(); + + mIsMobileScrolling = false; + handleMobileCellScroll(); + + return false; + } + break; + case MotionEvent.ACTION_UP: + touchEventsEnded(); + break; + case MotionEvent.ACTION_CANCEL: + touchEventsCancelled(); + break; + case MotionEvent.ACTION_POINTER_UP: + /* If a multitouch event took place and the original touch dictating + * the movement of the hover cell has ended, then the dragging event + * ends and the hover cell is animated to its corresponding position + * in the listview. */ + pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> + MotionEvent.ACTION_POINTER_INDEX_SHIFT; + final int pointerId = event.getPointerId(pointerIndex); + if (pointerId == mActivePointerId) { + touchEventsEnded(); + } + break; + default: + break; + } + + return super.onTouchEvent(event); + } + + /** + * This method determines whether the hover cell has been shifted far enough + * to invoke a cell onSwapItems. If so, then the respective cell onSwapItems candidate is + * determined and the data set is changed. Upon posting a notification of the + * data set change, a layout is invoked to place the cells in the right place. + * Using a ViewTreeObserver and a corresponding OnPreDrawListener, we can + * offset the cell being swapped to where it previously was and then animate it to + * its new position. + */ + private void handleCellSwitch() { + final int deltaY = mLastEventY - mDownY; + int deltaYTotal = mHoverCellOriginalBounds.top + mTotalOffset + deltaY; + + View belowView = getViewForID(mBelowItemId); + View mobileView = getViewForID(mMobileItemId); + View aboveView = getViewForID(mAboveItemId); + + boolean isBelow = (belowView != null) && (deltaYTotal > belowView.getTop()); + boolean isAbove = (aboveView != null) && (deltaYTotal < aboveView.getTop()); + + if (isBelow || isAbove) { + + final long switchItemID = isBelow ? mBelowItemId : mAboveItemId; + View switchView = isBelow ? belowView : aboveView; + if (switchView == null) { + updateNeighborViewsForID(mMobileItemId); + return; + } + + int originalItem = getPositionForView(mobileView); + int switchViewItem = getPositionForView(switchView); + + updateCheckedItemPositions(originalItem, switchViewItem); + + BaseAdapter adapter = (BaseAdapter) getAdapter(); + ((DynamicListAdapter) adapter).onSwapItems(originalItem, switchViewItem); + adapter.notifyDataSetChanged(); + + mDownY = mLastEventY; + + final int switchViewStartTop = switchView.getTop(); + + mobileView.setVisibility(View.VISIBLE); + switchView.setVisibility(View.INVISIBLE); + + updateNeighborViewsForID(mMobileItemId); + + final ViewTreeObserver observer = getViewTreeObserver(); + observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + public boolean onPreDraw() { + observer.removeOnPreDrawListener(this); + + View switchView = getViewForID(switchItemID); + + mTotalOffset += deltaY; + + int switchViewNewTop = switchView.getTop(); + int delta = switchViewStartTop - switchViewNewTop; + + switchView.setTranslationY(delta); + + ObjectAnimator animator = ObjectAnimator.ofFloat(switchView, + View.TRANSLATION_Y, 0); + animator.setDuration(MOVE_DURATION); + animator.start(); + + return true; + } + }); + } + } + + /** + * Resets all the appropriate fields to a default state while also animating + * the hover cell back to its correct location. + */ + private void touchEventsEnded () { + itemBeingDragged = false; + final View mobileView = getViewForID(mMobileItemId); + if (mobileView == null) { + mAboveItemId = INVALID_ID; + mMobileItemId = INVALID_ID; + mBelowItemId = INVALID_ID; + mHoverCell = null; + invalidate(); + return; + } + + if (mCellIsMobile|| mIsWaitingForScrollFinish) { + mCellIsMobile = false; + mIsWaitingForScrollFinish = false; + mIsMobileScrolling = false; + mActivePointerId = INVALID_POINTER_ID; + + int finalPosition = getPositionForView(mobileView); + if( finalPosition != mOriginalPosition ) { + ((DynamicListAdapter) getAdapter()).onSwapFinished(mOriginalPosition, finalPosition); + } + + // If the autoscroller has not completed scrolling, we need to wait for it to + // finish in order to determine the final location of where the hover cell + // should be animated to. + if (mScrollState != OnScrollListener.SCROLL_STATE_IDLE) { + mIsWaitingForScrollFinish = true; + return; + } + + mHoverCellCurrentBounds.offsetTo(mHoverCellOriginalBounds.left, mobileView.getTop()); + + ObjectAnimator hoverViewAnimator = ObjectAnimator.ofObject(mHoverCell, "bounds", + sBoundEvaluator, mHoverCellCurrentBounds); + hoverViewAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + invalidate(); + } + }); + hoverViewAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + setEnabled(false); + } + + @Override + public void onAnimationEnd(Animator animation) { + mAboveItemId = INVALID_ID; + mMobileItemId = INVALID_ID; + mBelowItemId = INVALID_ID; + mobileView.setVisibility(VISIBLE); + mHoverCell = null; + setEnabled(true); + invalidate(); + } + }); + hoverViewAnimator.start(); + } else { + touchEventsCancelled(); + } + } + + /** + * Resets all the appropriate fields to a default state. + */ + private void touchEventsCancelled () { + itemBeingDragged = false; + View mobileView = getViewForID(mMobileItemId); + if (mCellIsMobile) { + mAboveItemId = INVALID_ID; + mMobileItemId = INVALID_ID; + mBelowItemId = INVALID_ID; + mobileView.setVisibility(VISIBLE); + mHoverCell = null; + invalidate(); + } + mCellIsMobile = false; + mIsMobileScrolling = false; + mActivePointerId = INVALID_POINTER_ID; + } + + /** + * When swapping items the checked item positions in the list may need to be updated + * @param originalPosition + * @param newPosition + */ + private void updateCheckedItemPositions(int originalPosition, int newPosition) { + switch (getChoiceMode()) { + case CHOICE_MODE_SINGLE: + int checkPos = getCheckedItemPosition(); + if (checkPos == newPosition) { + setItemChecked(originalPosition, true); + } else if (checkPos == originalPosition) { + setItemChecked(newPosition, false); + } + break; + case CHOICE_MODE_MULTIPLE: + SparseBooleanArray checkedItems = getCheckedItemPositions(); + setItemChecked(originalPosition, checkedItems.get(newPosition)); + setItemChecked(newPosition, checkedItems.get(originalPosition)); + } + } + + /** + * This TypeEvaluator is used to animate the BitmapDrawable back to its + * final location when the user lifts his finger by modifying the + * BitmapDrawable's bounds. + */ + private final static TypeEvaluator sBoundEvaluator = new TypeEvaluator() { + public Rect evaluate(float fraction, Rect startValue, Rect endValue) { + return new Rect(interpolate(startValue.left, endValue.left, fraction), + interpolate(startValue.top, endValue.top, fraction), + interpolate(startValue.right, endValue.right, fraction), + interpolate(startValue.bottom, endValue.bottom, fraction)); + } + + public int interpolate(int start, int end, float fraction) { + return (int)(start + fraction * (end - start)); + } + }; + + /** + * Determines whether this listview is in a scrolling state invoked + * by the fact that the hover cell is out of the bounds of the listview; + */ + private void handleMobileCellScroll() { + mIsMobileScrolling = handleMobileCellScroll(mHoverCellCurrentBounds); + } + + /** + * This method is in charge of determining if the hover cell is above + * or below the bounds of the listview. If so, the listview does an appropriate + * upward or downward smooth scroll so as to reveal new items. + */ + public boolean handleMobileCellScroll(Rect r) { + int offset = computeVerticalScrollOffset(); + int height = getHeight(); + int extent = computeVerticalScrollExtent(); + int range = computeVerticalScrollRange(); + int hoverViewTop = r.top; + int hoverHeight = r.height(); + + if (hoverViewTop <= 0 && offset > 0) { + smoothScrollBy(-mSmoothScrollAmountAtEdge, 0); + return true; + } + + if (hoverViewTop + hoverHeight >= height && (offset + extent) < range) { + smoothScrollBy(mSmoothScrollAmountAtEdge, 0); + return true; + } + + return false; + } + + /** + * This scroll listener is added to the listview in order to handle cell swapping + * when the cell is either at the top or bottom edge of the listview. If the hover + * cell is at either edge of the listview, the listview will begin scrolling. As + * scrolling takes place, the listview continuously checks if new cells became visible + * and determines whether they are potential candidates for a cell onSwapItems. + */ + private AbsListView.OnScrollListener mScrollListener = new AbsListView.OnScrollListener () { + + private int mPreviousFirstVisibleItem = -1; + private int mPreviousVisibleItemCount = -1; + private int mCurrentFirstVisibleItem; + private int mCurrentVisibleItemCount; + private int mCurrentScrollState; + + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, + int totalItemCount) { + mCurrentFirstVisibleItem = firstVisibleItem; + mCurrentVisibleItemCount = visibleItemCount; + + mPreviousFirstVisibleItem = (mPreviousFirstVisibleItem == -1) ? mCurrentFirstVisibleItem + : mPreviousFirstVisibleItem; + mPreviousVisibleItemCount = (mPreviousVisibleItemCount == -1) ? mCurrentVisibleItemCount + : mPreviousVisibleItemCount; + + checkAndHandleFirstVisibleCellChange(); + checkAndHandleLastVisibleCellChange(); + + mPreviousFirstVisibleItem = mCurrentFirstVisibleItem; + mPreviousVisibleItemCount = mCurrentVisibleItemCount; + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + mCurrentScrollState = scrollState; + mScrollState = scrollState; + isScrollCompleted(); + } + + /** + * This method is in charge of invoking 1 of 2 actions. Firstly, if the listview + * is in a state of scrolling invoked by the hover cell being outside the bounds + * of the listview, then this scrolling event is continued. Secondly, if the hover + * cell has already been released, this invokes the animation for the hover cell + * to return to its correct position after the listview has entered an idle scroll + * state. + */ + private void isScrollCompleted() { + if (mCurrentVisibleItemCount > 0 && mCurrentScrollState == SCROLL_STATE_IDLE) { + if (mCellIsMobile && mIsMobileScrolling) { + handleMobileCellScroll(); + } else if (mIsWaitingForScrollFinish) { + touchEventsEnded(); + } + } + } + + /** + * Determines if the listview scrolled up enough to reveal a new cell at the + * top of the list. If so, then the appropriate parameters are updated. + */ + public void checkAndHandleFirstVisibleCellChange() { + if (mCurrentFirstVisibleItem != mPreviousFirstVisibleItem) { + if (mCellIsMobile && mMobileItemId != INVALID_ID) { + updateNeighborViewsForID(mMobileItemId); + handleCellSwitch(); + } + } + } + + /** + * Determines if the listview scrolled down enough to reveal a new cell at the + * bottom of the list. If so, then the appropriate parameters are updated. + */ + public void checkAndHandleLastVisibleCellChange() { + int currentLastVisibleItem = mCurrentFirstVisibleItem + mCurrentVisibleItemCount; + int previousLastVisibleItem = mPreviousFirstVisibleItem + mPreviousVisibleItemCount; + if (currentLastVisibleItem != previousLastVisibleItem) { + if (mCellIsMobile && mMobileItemId != INVALID_ID) { + updateNeighborViewsForID(mMobileItemId); + handleCellSwitch(); + } + } + } + }; +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playlist.xml b/app/src/main/res/layout/fragment_playlist.xml index b5ec139..8e4e8d6 100644 --- a/app/src/main/res/layout/fragment_playlist.xml +++ b/app/src/main/res/layout/fragment_playlist.xml @@ -23,7 +23,7 @@ - - - - - + android:fastScrollEnabled="true" + android:divider="@android:color/transparent" + android:dividerHeight="@dimen/default_grid_vertical_spacing"/> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b296b6..fd73906 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -361,5 +361,7 @@ Record Guide + Unable to move item + Cannot move currently playing/paused item