/*
* Copyright (C) 2023 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 com.android.launcher3.celllayout;
import android.graphics.Rect;
import android.view.View;
import com.android.launcher3.CellLayout;
import com.android.launcher3.util.CellAndSpan;
import com.android.launcher3.util.GridOccupancy;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map.Entry;
import java.util.stream.Collectors;
/**
* Contains the logic of a reorder.
*
* The content of this class was extracted from {@link CellLayout} and should mimic the exact
* same behaviour.
*/
public class ReorderAlgorithm {
CellLayout mCellLayout;
public ReorderAlgorithm(CellLayout cellLayout) {
mCellLayout = cellLayout;
}
/**
* This method differs from closestEmptySpaceReorder and dropInPlaceSolution because this method
* will move items around and will change the shape of the item if possible to try to find a
* solution.
*
* When changing the size of the widget this method will try first subtracting -1 in the x
* dimension and then subtracting -1 in the y dimension until finding a possible solution or
* until it no longer can reduce the span.
* @param decX whether it will decrease the horizontal or vertical span if it can't find a
* solution for the current span.
* @return the same solution variable
*/
public ItemConfiguration findReorderSolution(ReorderParameters reorderParameters,
boolean decX) {
return findReorderSolution(reorderParameters, mCellLayout.mDirectionVector, decX);
}
/**
* This method differs from closestEmptySpaceReorder and dropInPlaceSolution because this method
* will move items around and will change the shape of the item if possible to try to find a
* solution.
*
* When changing the size of the widget this method will try first subtracting -1 in the x
* dimension and then subtracting -1 in the y dimension until finding a possible solution or
* until it no longer can reduce the span.
* @param direction Direction to attempt to push items if needed
* @param decX whether it will decrease the horizontal or vertical span if it can't find a
* solution for the current span.
* @return the same solution variable
*/
public ItemConfiguration findReorderSolution(ReorderParameters reorderParameters,
int[] direction, boolean decX) {
return findReorderSolutionRecursive(reorderParameters.getPixelX(),
reorderParameters.getPixelY(), reorderParameters.getMinSpanX(),
reorderParameters.getMinSpanY(), reorderParameters.getSpanX(),
reorderParameters.getSpanY(), direction,
reorderParameters.getDragView(), decX, reorderParameters.getSolution());
}
private ItemConfiguration findReorderSolutionRecursive(int pixelX, int pixelY, int minSpanX,
int minSpanY, int spanX, int spanY, int[] direction, View dragView, boolean decX,
ItemConfiguration solution) {
// Copy the current state into the solution. This solution will be manipulated as necessary.
mCellLayout.copyCurrentStateToSolution(solution);
// Copy the current occupied array into the temporary occupied array. This array will be
// manipulated as necessary to find a solution.
mCellLayout.getOccupied().copyTo(mCellLayout.mTmpOccupied);
// We find the nearest cell into which we would place the dragged item, assuming there's
// nothing in its way.
int[] result = new int[2];
result = mCellLayout.findNearestAreaIgnoreOccupied(pixelX, pixelY, spanX, spanY, result);
boolean success;
// First we try the exact nearest position of the item being dragged,
// we will then want to try to move this around to other neighbouring positions
success = rearrangementExists(result[0], result[1], spanX, spanY, direction, dragView,
solution);
if (!success) {
// We try shrinking the widget down to size in an alternating pattern, shrink 1 in
// x, then 1 in y etc.
if (spanX > minSpanX && (minSpanY == spanY || decX)) {
return findReorderSolutionRecursive(pixelX, pixelY, minSpanX, minSpanY, spanX - 1,
spanY, direction, dragView, false, solution);
} else if (spanY > minSpanY) {
return findReorderSolutionRecursive(pixelX, pixelY, minSpanX, minSpanY, spanX,
spanY - 1, direction, dragView, true, solution);
}
solution.isSolution = false;
} else {
solution.isSolution = true;
solution.cellX = result[0];
solution.cellY = result[1];
solution.spanX = spanX;
solution.spanY = spanY;
}
return solution;
}
private boolean rearrangementExists(int cellX, int cellY, int spanX, int spanY, int[] direction,
View ignoreView, ItemConfiguration solution) {
// Return early if get invalid cell positions
if (cellX < 0 || cellY < 0) return false;
ArrayList intersectingViews = new ArrayList<>();
Rect occupiedRect = new Rect(cellX, cellY, cellX + spanX, cellY + spanY);
// Mark the desired location of the view currently being dragged.
if (ignoreView != null) {
CellAndSpan c = solution.map.get(ignoreView);
if (c != null) {
c.cellX = cellX;
c.cellY = cellY;
}
}
Rect r0 = new Rect(cellX, cellY, cellX + spanX, cellY + spanY);
Rect r1 = new Rect();
// The views need to be sorted so that the results are deterministic on the views positions
// and not by the views hash which is "random".
// The views are sorted twice, once for the X position and a second time for the Y position
// to ensure same order everytime.
Comparator comparator = Comparator.comparing(
(View view) -> ((CellLayoutLayoutParams) view.getLayoutParams()).getCellX()
).thenComparing(
(View view) -> ((CellLayoutLayoutParams) view.getLayoutParams()).getCellY()
);
List views = solution.map.keySet().stream()
.sorted(comparator)
.collect(Collectors.toList());
for (View child : views) {
if (child == ignoreView) continue;
CellAndSpan c = solution.map.get(child);
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) child.getLayoutParams();
r1.set(c.cellX, c.cellY, c.cellX + c.spanX, c.cellY + c.spanY);
if (Rect.intersects(r0, r1)) {
if (!lp.canReorder) {
return false;
}
intersectingViews.add(child);
}
}
solution.intersectingViews = intersectingViews;
// First we try to find a solution which respects the push mechanic. That is,
// we try to find a solution such that no displaced item travels through another item
// without also displacing that item.
if (attemptPushInDirection(intersectingViews, occupiedRect, direction, ignoreView,
solution)) {
return true;
}
// Next we try moving the views as a block, but without requiring the push mechanic.
if (addViewsToTempLocation(intersectingViews, occupiedRect, direction, ignoreView,
solution)) {
return true;
}
// Ok, they couldn't move as a block, let's move them individually
for (View v : intersectingViews) {
if (!addViewToTempLocation(v, occupiedRect, direction, solution)) {
return false;
}
}
return true;
}
private boolean addViewToTempLocation(View v, Rect rectOccupiedByPotentialDrop, int[] direction,
ItemConfiguration currentState) {
CellAndSpan c = currentState.map.get(v);
boolean success = false;
mCellLayout.mTmpOccupied.markCells(c, false);
mCellLayout.mTmpOccupied.markCells(rectOccupiedByPotentialDrop, true);
int[] tmpLocation = findNearestArea(c.cellX, c.cellY, c.spanX, c.spanY, direction,
mCellLayout.mTmpOccupied.cells, null, new int[2]);
if (tmpLocation[0] >= 0 && tmpLocation[1] >= 0) {
c.cellX = tmpLocation[0];
c.cellY = tmpLocation[1];
success = true;
}
mCellLayout.mTmpOccupied.markCells(c, true);
return success;
}
private boolean pushViewsToTempLocation(ArrayList views, Rect rectOccupiedByPotentialDrop,
int[] direction, View dragView, ItemConfiguration currentState) {
ViewCluster cluster = new ViewCluster(mCellLayout, views, currentState);
Rect clusterRect = cluster.getBoundingRect();
int whichEdge;
int pushDistance;
boolean fail = false;
// Determine the edge of the cluster that will be leading the push and how far
// the cluster must be shifted.
if (direction[0] < 0) {
whichEdge = ViewCluster.LEFT;
pushDistance = clusterRect.right - rectOccupiedByPotentialDrop.left;
} else if (direction[0] > 0) {
whichEdge = ViewCluster.RIGHT;
pushDistance = rectOccupiedByPotentialDrop.right - clusterRect.left;
} else if (direction[1] < 0) {
whichEdge = ViewCluster.TOP;
pushDistance = clusterRect.bottom - rectOccupiedByPotentialDrop.top;
} else {
whichEdge = ViewCluster.BOTTOM;
pushDistance = rectOccupiedByPotentialDrop.bottom - clusterRect.top;
}
// Break early for invalid push distance.
if (pushDistance <= 0) {
return false;
}
// Mark the occupied state as false for the group of views we want to move.
for (View v : views) {
CellAndSpan c = currentState.map.get(v);
mCellLayout.mTmpOccupied.markCells(c, false);
}
// We save the current configuration -- if we fail to find a solution we will revert
// to the initial state. The process of finding a solution modifies the configuration
// in place, hence the need for revert in the failure case.
currentState.save();
// The pushing algorithm is simplified by considering the views in the order in which
// they would be pushed by the cluster. For example, if the cluster is leading with its
// left edge, we consider sort the views by their right edge, from right to left.
cluster.sortConfigurationForEdgePush(whichEdge);
while (pushDistance > 0 && !fail) {
for (View v : currentState.sortedViews) {
// For each view that isn't in the cluster, we see if the leading edge of the
// cluster is contacting the edge of that view. If so, we add that view to the
// cluster.
if (!cluster.views.contains(v) && v != dragView) {
if (cluster.isViewTouchingEdge(v, whichEdge)) {
CellLayoutLayoutParams lp = (CellLayoutLayoutParams) v.getLayoutParams();
if (!lp.canReorder) {
// The push solution includes the all apps button, this is not viable.
fail = true;
break;
}
cluster.addView(v);
CellAndSpan c = currentState.map.get(v);
// Adding view to cluster, mark it as not occupied.
mCellLayout.mTmpOccupied.markCells(c, false);
}
}
}
pushDistance--;
// The cluster has been completed, now we move the whole thing over in the appropriate
// direction.
cluster.shift(whichEdge, 1);
}
boolean foundSolution = false;
clusterRect = cluster.getBoundingRect();
// Due to the nature of the algorithm, the only check required to verify a valid solution
// is to ensure that completed shifted cluster lies completely within the cell layout.
if (!fail && clusterRect.left >= 0 && clusterRect.right <= mCellLayout.getCountX()
&& clusterRect.top >= 0 && clusterRect.bottom <= mCellLayout.getCountY()) {
foundSolution = true;
} else {
currentState.restore();
}
// In either case, we set the occupied array as marked for the location of the views
for (View v : cluster.views) {
CellAndSpan c = currentState.map.get(v);
mCellLayout.mTmpOccupied.markCells(c, true);
}
return foundSolution;
}
private void revertDir(int[] direction) {
direction[0] *= -1;
direction[1] *= -1;
}
// This method tries to find a reordering solution which satisfies the push mechanic by trying
// to push items in each of the cardinal directions, in an order based on the direction vector
// passed.
private boolean attemptPushInDirection(ArrayList intersectingViews, Rect occupied,
int[] direction, View ignoreView, ItemConfiguration solution) {
if ((Math.abs(direction[0]) + Math.abs(direction[1])) > 1) {
// If the direction vector has two non-zero components, we try pushing
// separately in each of the components.
int temp;
for (int j = 0; j < 2; j++) {
for (int i = 1; i >= 0; i--) {
temp = direction[i];
direction[i] = 0;
if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
solution)) {
return true;
}
direction[i] = temp;
}
revertDir(direction);
}
} else {
// If the direction vector has a single non-zero component, we push first in the
// direction of the vector
int temp;
for (int j = 0; j < 2; j++) {
for (int i = 0; i < 2; i++) {
if (pushViewsToTempLocation(intersectingViews, occupied, direction, ignoreView,
solution)) {
return true;
}
revertDir(direction);
}
// Swap the components
temp = direction[1];
direction[1] = direction[0];
direction[0] = temp;
}
}
return false;
}
private boolean addViewsToTempLocation(ArrayList views, Rect rectOccupiedByPotentialDrop,
int[] direction, View dragView, ItemConfiguration currentState) {
if (views.isEmpty()) return true;
boolean success = false;
Rect boundingRect = new Rect();
// We construct a rect which represents the entire group of views passed in
currentState.getBoundingRectForViews(views, boundingRect);
// Mark the occupied state as false for the group of views we want to move.
for (View v : views) {
CellAndSpan c = currentState.map.get(v);
mCellLayout.mTmpOccupied.markCells(c, false);
}
GridOccupancy blockOccupied = new GridOccupancy(boundingRect.width(),
boundingRect.height());
int top = boundingRect.top;
int left = boundingRect.left;
// We mark more precisely which parts of the bounding rect are truly occupied, allowing
// for interlocking.
for (View v : views) {
CellAndSpan c = currentState.map.get(v);
blockOccupied.markCells(c.cellX - left, c.cellY - top, c.spanX, c.spanY, true);
}
mCellLayout.mTmpOccupied.markCells(rectOccupiedByPotentialDrop, true);
int[] tmpLocation = findNearestArea(boundingRect.left, boundingRect.top,
boundingRect.width(), boundingRect.height(), direction,
mCellLayout.mTmpOccupied.cells, blockOccupied.cells, new int[2]);
// If we successfully found a location by pushing the block of views, we commit it
if (tmpLocation[0] >= 0 && tmpLocation[1] >= 0) {
int deltaX = tmpLocation[0] - boundingRect.left;
int deltaY = tmpLocation[1] - boundingRect.top;
for (View v : views) {
CellAndSpan c = currentState.map.get(v);
c.cellX += deltaX;
c.cellY += deltaY;
}
success = true;
}
// In either case, we set the occupied array as marked for the location of the views
for (View v : views) {
CellAndSpan c = currentState.map.get(v);
mCellLayout.mTmpOccupied.markCells(c, true);
}
return success;
}
/**
* Returns a "reorder" if there is empty space without rearranging anything.
*
* @return the configuration that represents the found reorder
*/
public ItemConfiguration dropInPlaceSolution(ReorderParameters reorderParameters) {
int[] result = mCellLayout.findNearestAreaIgnoreOccupied(reorderParameters.getPixelX(),
reorderParameters.getPixelY(), reorderParameters.getSpanX(),
reorderParameters.getSpanY(), new int[2]);
ItemConfiguration solution = new ItemConfiguration();
mCellLayout.copyCurrentStateToSolution(solution);
solution.isSolution = !isConfigurationRegionOccupied(
new Rect(result[0], result[1], result[0] + reorderParameters.getSpanX(),
result[1] + reorderParameters.getSpanY()), solution,
reorderParameters.getDragView());
if (!solution.isSolution) {
return solution;
}
solution.cellX = result[0];
solution.cellY = result[1];
solution.spanX = reorderParameters.getSpanX();
solution.spanY = reorderParameters.getSpanY();
return solution;
}
private boolean isConfigurationRegionOccupied(Rect region, ItemConfiguration configuration,
View ignoreView) {
return configuration.map
.entrySet()
.stream()
.filter(entry -> entry.getKey() != ignoreView)
.map(Entry::getValue)
.anyMatch(cellAndSpan -> region.intersect(
cellAndSpan.cellX,
cellAndSpan.cellY,
cellAndSpan.cellX + cellAndSpan.spanX,
cellAndSpan.cellY + cellAndSpan.spanY
)
);
}
/**
* Returns a "reorder" where we simply drop the item in the closest empty space, without moving
* any other item in the way.
*
* @return the configuration that represents the found reorder
*/
public ItemConfiguration closestEmptySpaceReorder(ReorderParameters reorderParameters) {
ItemConfiguration solution = new ItemConfiguration();
int[] result = new int[2];
int[] resultSpan = new int[2];
mCellLayout.findNearestVacantArea(reorderParameters.getPixelX(),
reorderParameters.getPixelY(), reorderParameters.getMinSpanX(),
reorderParameters.getMinSpanY(), reorderParameters.getSpanX(),
reorderParameters.getSpanY(), result, resultSpan);
if (result[0] >= 0 && result[1] >= 0) {
mCellLayout.copyCurrentStateToSolution(solution);
solution.cellX = result[0];
solution.cellY = result[1];
solution.spanX = resultSpan[0];
solution.spanY = resultSpan[1];
solution.isSolution = true;
} else {
solution.isSolution = false;
}
return solution;
}
/**
* When the user drags an Item in the workspace sometimes we need to move the items already in
* the workspace to make space for the new item, this function return a solution for that
* reorder.
*
* @return returns a solution for the given parameters, the solution contains all the icons and
* the locations they should be in the given solution.
*/
public ItemConfiguration calculateReorder(ReorderParameters reorderParameters) {
getDirectionVectorForDrop(reorderParameters, mCellLayout.mDirectionVector);
ItemConfiguration dropInPlaceSolution = dropInPlaceSolution(reorderParameters);
// Find a solution involving pushing / displacing any items in the way
ItemConfiguration swapSolution = findReorderSolution(reorderParameters, true);
// We attempt the approach which doesn't shuffle views at all
ItemConfiguration closestSpaceSolution = closestEmptySpaceReorder(reorderParameters);
// If the reorder solution requires resizing (shrinking) the item being dropped, we instead
// favor a solution in which the item is not resized, but
if (swapSolution.isSolution && swapSolution.area() >= closestSpaceSolution.area()) {
return swapSolution;
} else if (closestSpaceSolution.isSolution) {
return closestSpaceSolution;
} else if (dropInPlaceSolution.isSolution) {
return dropInPlaceSolution;
}
return null;
}
/*
* Returns a pair (x, y), where x,y are in {-1, 0, 1} corresponding to vector between
* the provided point and the provided cell
*/
private void computeDirectionVector(float deltaX, float deltaY, int[] result) {
double angle = Math.atan(deltaY / deltaX);
result[0] = 0;
result[1] = 0;
if (Math.abs(Math.cos(angle)) > 0.5f) {
result[0] = (int) Math.signum(deltaX);
}
if (Math.abs(Math.sin(angle)) > 0.5f) {
result[1] = (int) Math.signum(deltaY);
}
}
/**
* This seems like it should be obvious and straight-forward, but when the direction vector
* needs to match with the notion of the dragView pushing other views, we have to employ
* a slightly more subtle notion of the direction vector. The question is what two points is
* the vector between? The center of the dragView and its desired destination? Not quite, as
* this doesn't necessarily coincide with the interaction of the dragView and items occupying
* those cells. Instead we use some heuristics to often lock the vector to up, down, left
* or right, which helps make pushing feel right.
*/
public void getDirectionVectorForDrop(ReorderParameters reorderParameters,
int[] resultDirection) {
//TODO(adamcohen) b/151776141 use the items visual center for the direction vector
int[] targetDestination = new int[2];
mCellLayout.findNearestAreaIgnoreOccupied(reorderParameters.getPixelX(),
reorderParameters.getPixelY(), reorderParameters.getSpanX(),
reorderParameters.getSpanY(), targetDestination);
Rect dragRect = new Rect();
mCellLayout.cellToRect(targetDestination[0], targetDestination[1],
reorderParameters.getSpanX(), reorderParameters.getSpanY(), dragRect);
dragRect.offset(reorderParameters.getPixelX() - dragRect.centerX(),
reorderParameters.getPixelY() - dragRect.centerY());
Rect region = new Rect(targetDestination[0], targetDestination[1],
targetDestination[0] + reorderParameters.getSpanX(),
targetDestination[1] + reorderParameters.getSpanY());
Rect dropRegionRect = mCellLayout.getIntersectingRectanglesInRegion(region,
reorderParameters.getDragView());
if (dropRegionRect == null) dropRegionRect = new Rect(region);
int dropRegionSpanX = dropRegionRect.width();
int dropRegionSpanY = dropRegionRect.height();
mCellLayout.cellToRect(dropRegionRect.left, dropRegionRect.top, dropRegionRect.width(),
dropRegionRect.height(), dropRegionRect);
int deltaX = (dropRegionRect.centerX() - reorderParameters.getPixelX())
/ reorderParameters.getSpanX();
int deltaY = (dropRegionRect.centerY() - reorderParameters.getPixelY())
/ reorderParameters.getSpanY();
if (dropRegionSpanX == mCellLayout.getCountX()
|| reorderParameters.getSpanX() == mCellLayout.getCountX()) {
deltaX = 0;
}
if (dropRegionSpanY == mCellLayout.getCountY()
|| reorderParameters.getSpanY() == mCellLayout.getCountY()) {
deltaY = 0;
}
if (deltaX == 0 && deltaY == 0) {
// No idea what to do, give a random direction.
resultDirection[0] = 1;
resultDirection[1] = 0;
} else {
computeDirectionVector(deltaX, deltaY, resultDirection);
}
}
/**
* Find a vacant area that will fit the given bounds nearest the requested
* cell location, and will also weigh in a suggested direction vector of the
* desired location. This method computers distance based on unit grid distances,
* not pixel distances.
*
* @param cellX The X cell nearest to which you want to search for a vacant area.
* @param cellY The Y cell nearest which you want to search for a vacant area.
* @param spanX Horizontal span of the object.
* @param spanY Vertical span of the object.
* @param direction The favored direction in which the views should move from x, y
* @param occupied The array which represents which cells in the CellLayout are occupied
* @param blockOccupied The array which represents which cells in the specified block (cellX,
* cellY, spanX, spanY) are occupied. This is used when try to move a group
* of views.
* @param result Array in which to place the result, or null (in which case a new array
* will
* be allocated)
* @return The X, Y cell of a vacant area that can contain this object,
* nearest the requested location.
*/
public int[] findNearestArea(int cellX, int cellY, int spanX, int spanY, int[] direction,
boolean[][] occupied, boolean[][] blockOccupied, int[] result) {
// Keep track of best-scoring drop area
final int[] bestXY = result != null ? result : new int[2];
float bestDistance = Float.MAX_VALUE;
int bestDirectionScore = Integer.MIN_VALUE;
final int countX = mCellLayout.getCountX();
final int countY = mCellLayout.getCountY();
for (int y = 0; y < countY - (spanY - 1); y++) {
inner:
for (int x = 0; x < countX - (spanX - 1); x++) {
// First, let's see if this thing fits anywhere
for (int i = 0; i < spanX; i++) {
for (int j = 0; j < spanY; j++) {
if (occupied[x + i][y + j] && (blockOccupied == null
|| blockOccupied[i][j])) {
continue inner;
}
}
}
float distance = (float) Math.hypot(x - cellX, y - cellY);
int[] curDirection = new int[2];
computeDirectionVector(x - cellX, y - cellY, curDirection);
// The direction score is just the dot product of the two candidate direction
// and that passed in.
int curDirectionScore =
direction[0] * curDirection[0] + direction[1] * curDirection[1];
if (Float.compare(distance, bestDistance) < 0 || (Float.compare(distance,
bestDistance) == 0 && curDirectionScore > bestDirectionScore)) {
bestDistance = distance;
bestDirectionScore = curDirectionScore;
bestXY[0] = x;
bestXY[1] = y;
}
}
}
// Return -1, -1 if no suitable location found
if (bestDistance == Float.MAX_VALUE) {
bestXY[0] = -1;
bestXY[1] = -1;
}
return bestXY;
}
}