0

[MVT Customization] Add basic Custom Tile drag to reorder.

This CL adds Custom Tile (CT) drag to reorder feature for Clank:
* Long-click on a "from" CT causes it to reduce in size, and become
  draggable horizontally among other CT.
  * Long-click also causes the context menu to appear, but after moving
    touch "far enough" the dialog closes, and the drag UI "dominates".
* Dragging the "from" CT to a different "to" CT causes all CT between
  the two tiles to shift towards the gap left by the "from" CT.
* Releasing the touch moves the "from" CT to the position of the "to"
  CT, with all tiles between shifted as well.

TODO in follow-up:
* Scroll MVT when "from" CT is dragged to left / right edge.

Reordering combines tile long-click and drag, and also needs to coexist
with other tile touch actions, i.e.:
* Press + (quickly) release => Click tile to visit.
* Press + (quickly) drag up / down => Scroll NTP and/or refresh.
* Press + (quickly) drag left / right => Scroll MVT.
* Press + hold, also after release => Tile context menu shows.

The new interactions (for CTs only) is compatible with the above:
1. Press + hold => Start dragging.
2. Press + hold + drag => Visually move tiles.
3. Press + hold + drag farther => Hide context menu.
4. Press + hold + drag (on different CT) + release => Reorder tiles.

Item reordering under similar constraints is also implemented in the Tab
Switcher. However, their implementation uses RecyclerView API, which
MostVisitedTilesLayout does not use. Therefore we implemented the
feature using custom code by handling onTouch() events on TileView.

Animation poses additional challenge to drag-and-drop:
* For (4): Tile reordering cause MVT refresh. An abrupt tile jump after
  touch releases is undesirable. To avoid this, for (4) we'd wait for
  animation to finish first, then execute reorder and refresh.
* Animation means UI race conditions can happen. Therefore we'd need to
  properly cancel or complete in-flight tasks when new ones appear.

Design and details:
* TileInteractionDelegateImpl::onTouch() is the event source.
* Add TileGroup.TileDragHandlerDelegate to interface with Chrome:
  * onDragDominate(): Handles (3).
  * onDragAccept(): Handles (4).
* Add TileGroup.TileDragDelegate for input and control:
  * onTileTouchDown(): Receives initial ACTION_DOWN, which starts a
    timer to trigger (1), leading to "session" start.
  * onSessionTileTouch(): Receives subsequent events for (2), (4).
  * hasSession(): For decision to call onSessionTileTouch().
  * reset(): Termination.
* Key API call to get ACTION_MOVE events (for the entire screen): Call
  requestDisallowInterceptTouchEvent(true) on MostVisitedTilesLayout.
* Add TileDragDelegateImpl to implement TileGroup.TileDragDelegate and
  centralize new UI logic.
  * Reusable. Has access to MostVisitedTilesLayout.
    * Requires instantiation from MostVisitedTilesMediator.
    * Injected into TileGroup.
  * Add inner class DragSession for ephemeral states.
    * NONE: (Inactive).
    * PREPARE: Reached by (1)'s press (onTileTouchDown()).
    * START: Reached by (1)'s hold (using timer), can do (2).
    * DOMINATE: Reached by (3)'s "drag farther", can do (2), (4).
      * On entrance: Call onDragDominate() (through TileDragSession).
  * Handles ACTION_CANCEL to reset to (1).
  * Gets and uses cancellation Runnable from TileDragSession.
* Add TileDragSession for ephemeral drag states.
  * Store data passed on (1) for "from" tile and transient UI data.
  * Calculates displacements / positions and moves tiles, using
    animation.
  * Finds "to" tile by distance between dragged "from" tile and tile
    candidates. This approach is agnostic of LTR vs. RTL.
  * finish(): Finish with animation, and on accept, calls
    onDragAccept(). Also returns cancellation Runnable.
* Add TileMovement to represent (draggable) tiles by indices and perform
  perform queries and operations.

Adjacent changes are:
* Add ContextMenuManager::hideListContextMenu() to enable hiding the
  context menu by code in onDragDominate() impl.
* Add plumbing from Java (across JNI) to
  MostVisitedTiles::ReorderCustomLink() for onDragAccept() impl.
* Issue: TileGroup::loadTiles() skips MVT rerender if the suggestions
  *set* (i.e., disregarding order) is unchanged. This blocks update on
  CT reordering. Our solution is to add `forceUpdate` flag, which is
  set to true on CT updates from onSiteSuggestionsAvailable().
* Add TileView.isDraggable(), overridden in SuggestionsTileView to
  return true for CTs and false otherwise.
* Add MostVisitedTilesLayout.getTileViewData() to enable retrieving
  SiteSuggestion from TileView.

Various parameters are constants in TileDragDelegateImpl, and can be
tuned later.

Bug: 397421687, 388782412
Change-Id: I28728abac940cf00aae6cc2ab80b767cf12a5980
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6502978
Reviewed-by: Calder Kitagawa <ckitagawa@chromium.org>
Commit-Queue: Samuel Huang <huangs@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1456984}
This commit is contained in:
Samuel Huang
2025-05-07 07:30:29 -07:00
committed by Chromium LUCI CQ
parent 284d957207
commit 5c61751af2
20 changed files with 842 additions and 38 deletions
chrome
components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/tile

@ -1007,9 +1007,12 @@ chrome_java_sources = [
"java/src/org/chromium/chrome/browser/suggestions/tile/MostVisitedTilesViewBinder.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/SuggestionsTileView.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/Tile.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileDragDelegateImpl.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileDragSession.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileGroup.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileGroupDelegateImpl.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileInteractionDelegateImpl.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileMovement.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileRenderer.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TileUtils.java",
"java/src/org/chromium/chrome/browser/suggestions/tile/TilesLinearLayout.java",

@ -322,6 +322,13 @@ public class ContextMenuManager implements OnCloseContextMenuListener {
return true;
}
/** Dismisses the context menu shown by {@link showListContextMenu()}, if any. */
public void hideListContextMenu() {
if (mListContextMenu != null) {
mListContextMenu.dismiss();
}
}
@Override
public void onContextMenuClosed() {
if (mAnchorView == null) return;

@ -48,4 +48,14 @@ public interface CustomLinkOperations {
* @return Whether a custom link identified by {@param keyUrl} exists.
*/
boolean hasCustomLink(GURL keyUrl);
/**
* Moves a custom link identified by {@param keyUrl} to a new position, and shift all other
* custom links between the old position and the new towards the former.
*
* @param keyUrl The URL of the custom link to move.
* @param newPos The new position for the custom link to move to.
* @return Whether the operation successfully ran.
*/
boolean reorderCustomLink(GURL keyUrl, int newPos);
}

@ -67,6 +67,13 @@ public class MostVisitedSitesBridge implements MostVisitedSites {
return MostVisitedSitesBridgeJni.get().hasCustomLink(mNativeMostVisitedSitesBridge, keyUrl);
}
@Override
public boolean reorderCustomLink(GURL keyUrl, int newPos) {
if (mNativeMostVisitedSitesBridge == 0) return false;
return MostVisitedSitesBridgeJni.get()
.reorderCustomLink(mNativeMostVisitedSitesBridge, keyUrl, newPos);
}
// MostVisitedSites implementation.
/**
* Cleans up the C++ side of this class. This instance must not be used after calling destroy().
@ -201,6 +208,9 @@ public class MostVisitedSitesBridge implements MostVisitedSites {
boolean hasCustomLink(long nativeMostVisitedSitesBridge, @JniType("GURL") GURL keyUrl);
boolean reorderCustomLink(
long nativeMostVisitedSitesBridge, @JniType("GURL") GURL keyUrl, int newPos);
void destroy(long nativeMostVisitedSitesBridge, MostVisitedSitesBridge caller);
void onHomepageStateChanged(

@ -57,6 +57,10 @@ public class MostVisitedTilesLayout extends TilesLinearLayout {
return null;
}
public SiteSuggestion getTileViewData(TileView tileView) {
return ((SuggestionsTileView) tileView).getData();
}
/**
* Adjusts the edge margin of the tile elements when they are displayed in the center of the NTP
* on the tablet.

@ -107,6 +107,7 @@ public class MostVisitedTilesMediator implements TileGroup.Observer, TemplateUrl
suggestionsUiDelegate,
contextMenuManager,
tileGroupDelegate,
new TileDragDelegateImpl(mMvTilesLayout),
/* observer= */ this,
offlinePageBridge);
mTileGroup.startObserving(MAX_RESULTS);

@ -27,6 +27,12 @@ public class SuggestionsTileView extends TileView {
super(context, attrs);
}
// TileView override.
@Override
public boolean isDraggable() {
return mData.source == TileSource.CUSTOM_LINKS;
}
/**
* Initializes the view using the data held by {@code tile}. This should be called immediately
* after inflation.

@ -0,0 +1,209 @@
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.suggestions.tile;
import android.content.res.Resources;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.IntDef;
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.suggestions.SiteSuggestion;
import org.chromium.components.browser_ui.widget.tile.TileView;
import org.chromium.ui.util.RunnableTimer;
import java.util.ArrayList;
import java.util.List;
/**
* UI logic for dragging a "from" tile to a "to" tile's location: Handles touch events, updates the
* the state machine. Reusable across drag sessions for a {@link MostVisitedTilesLayout} instance.
* Uses {@link TileDragSession} to store per-session states.
*/
@NullMarked
class TileDragDelegateImpl implements TileGroup.TileDragDelegate, TileDragSession.Delegate {
// Tile drag dynamics are represented by a state machine. Here are its states.
@IntDef({
DragPhase.NONE,
DragPhase.PREPARE,
DragPhase.START,
DragPhase.DOMINATE,
})
public @interface DragPhase {
// NONE: No drag. ACTION_DOWN => :=PREPARE (i.e., enter the PREPARE phase).
int NONE = 0;
// PREPARE: No drag. Default touch handling triggers ACTION_UP => tile click; ACTION_MOVE
// (after small drag) => scroll. These default interactions trigger ACTION_CANCEL => :=NONE.
// If PREPARE persists longer than "start duration" then :=START. TileDragHandlerDelegate
// is passed here.
int PREPARE = 1;
// START: Tile drag is live: ACTION_MOVE => move "from" tile; ACTION_UP => cancel drag
// :=NONE. If drag displacement exceeds "dominate threshold" then :=DOMINATE, and call
// TileDragHandlerDelegate.onDragDominate().
int START = 2;
// DOMINATE: Tile drag is live: ACTION_MOVE => move "from" and background tiles; ACTION_UP
// => finalize, which *may* call TileDragHandlerDelegate.onDragAccept(), and then :=NONE.
int DOMINATE = 3;
int NUM_ENTRIES = 4;
}
// "Start duration": Delay (in ms) for triggering PREPARE -> START change.
private static final long START_DURATION_MS = 300;
// Relative "dominate threshold": Multiplied by tile width to get "dominate threshold".
private static final float DOMINATE_TRESHOLD_RATIO = 0.4f;
// Parent container for dragged tiles.
private final MostVisitedTilesLayout mMvTilesLayout;
private final float mTileWidthPx;
// Squared "dominate threshold": During drag, if the ACTION_MOVE position's (Euclidean) distance
// to the ACTION_DOWN distance exceeds "dominate threshold" then START -> DOMINATE change
// triggers.
private final float mDominateThresholdPxSquared;
// Timer for PREPARE -> START change.
private final RunnableTimer mTimer = new RunnableTimer();
// Current UI phase.
private @DragPhase int mPhase;
// Ephemeral drag states: Null in EMPTY; assigned in PREPARE; active in {START, DOMINATE}.
private @Nullable TileDragSession mTileDragSession;
// Runnable to cancel tile movement that might not have completed yet, due to animation.
private @Nullable Runnable mPendingChangeCanceller;
public TileDragDelegateImpl(MostVisitedTilesLayout mvTilesLayout) {
mMvTilesLayout = mvTilesLayout;
Resources res = mMvTilesLayout.getResources();
mTileWidthPx = res.getDimensionPixelSize(R.dimen.tile_view_width);
float mDominateThresholdPx = DOMINATE_TRESHOLD_RATIO * mTileWidthPx;
mDominateThresholdPxSquared = mDominateThresholdPx * mDominateThresholdPx;
mPhase = DragPhase.NONE;
}
// TileGroup.TileDragDelegate implementation.
@Override
public void onTileTouchDown(
View view, MotionEvent event, TileGroup.TileDragHandlerDelegate dragHandlerDelegate) {
assert event.getAction() == MotionEvent.ACTION_DOWN;
if (!((TileView) view).isDraggable()) {
return;
}
resetInternal(false);
mPhase = DragPhase.PREPARE;
mTileDragSession =
new TileDragSession(
this, dragHandlerDelegate, (TileView) view, event.getX(), event.getY());
mTimer.startTimer(
START_DURATION_MS,
() -> {
if (mTileDragSession == null) {
assert mPhase == DragPhase.NONE;
resetInternal(false);
} else {
cancelPendingChange();
mPhase = DragPhase.START;
// Needed to do this caller to consistently receive ACTION_MOVE.
mMvTilesLayout.requestDisallowInterceptTouchEvent(true);
mTileDragSession.start();
}
});
}
@Override
public void onSessionTileTouch(View view, MotionEvent event) {
assert ((TileView) view).isDraggable() && mTileDragSession != null;
if (event.getAction() == MotionEvent.ACTION_MOVE) {
if (mPhase == DragPhase.START) {
float dragDisplacementSquared =
mTileDragSession.getDragDisplacementSquared(event.getX(), event.getY());
if (dragDisplacementSquared >= mDominateThresholdPxSquared) {
mTileDragSession.getTileDragHandlerDelegate().onDragDominate();
mPhase = DragPhase.DOMINATE;
} else {
mTileDragSession.updateFromView(event.getX());
}
}
if (mPhase == DragPhase.DOMINATE) {
mTileDragSession.updateFromView(event.getX());
mTileDragSession.updateToIndexAndAnimate();
}
} else if (event.getAction() == MotionEvent.ACTION_UP) {
resetInternal(mPhase == DragPhase.DOMINATE);
mPhase = DragPhase.NONE;
} else if (event.getAction() == MotionEvent.ACTION_CANCEL) {
resetInternal(false);
mPhase = DragPhase.NONE;
}
}
@Override
public boolean hasSession() {
return mPhase != DragPhase.NONE;
}
@Override
public void reset() {
resetInternal(false);
mPhase = DragPhase.NONE;
}
// TileDragSession.Delegate implementation.
@Override
public float getTileWidthPx() {
return mTileWidthPx;
}
@Override
public List<TileView> getDraggableTileViews() {
List<TileView> draggableTileViews = new ArrayList<TileView>();
int tileCount = mMvTilesLayout.getTileCount();
for (int i = 0; i < tileCount; ++i) {
TileView tileView = mMvTilesLayout.getTileAt(i);
if (tileView.isDraggable()) {
draggableTileViews.add(tileView);
}
}
return draggableTileViews;
}
@Override
public SiteSuggestion getTileViewData(TileView view) {
return mMvTilesLayout.getTileViewData(view);
}
private void cancelPendingChange() {
if (mPendingChangeCanceller != null) {
mPendingChangeCanceller.run();
mPendingChangeCanceller = null;
}
}
private void resetInternal(boolean accept) {
mMvTilesLayout.requestDisallowInterceptTouchEvent(false);
cancelPendingChange();
mTimer.cancelTimer();
if (mTileDragSession != null) {
mPendingChangeCanceller = mTileDragSession.finish(accept);
mTileDragSession = null;
}
}
}

@ -0,0 +1,202 @@
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.suggestions.tile;
import org.chromium.build.annotations.Initializer;
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
import org.chromium.chrome.browser.suggestions.SiteSuggestion;
import org.chromium.components.browser_ui.widget.tile.TileView;
import java.util.List;
/**
* Helper for {@link TileDragDelegateImpl} to manage session states and interface with {@link
* TileMovement}. W.r.t. {@link TileDragDelegateImpl.DragPhase}, the class is used in {PREPARE,
* START, DOMINATE}.
*/
@NullMarked
class TileDragSession {
// Delegate to retrieve data from {@link TileDragDelegateImpl}.
interface Delegate {
/**
* @return Width of a Most Visit Tile, in PX.
*/
float getTileWidthPx();
/**
* @return The current list of {@link TileView} instances in the MVT container.
*/
List<TileView> getDraggableTileViews();
/**
* @return The {@link SiteSuggestion} corresponding to a {@link TileView}.
*/
SiteSuggestion getTileViewData(TileView view);
}
// Scaling factor to shrink the "from" tile in {START, DOMINATE}.
private static final float DRAG_ACTIVE_SCALE = 0.8f;
// Relative X-margin: Multiplied by tile width to get the X-margin.
private static final float DRAG_X_MARGIN_RATIO = 0.2f;
private final Delegate mDelegate;
private final TileGroup.TileDragHandlerDelegate mDragResultDelegate;
private final TileView mFromView;
private final float mStartX;
private final float mStartY;
// Variables unneeded during PREPARE are initialized in start().
private @Nullable TileMovement mTileMovement;
private float mSavedSrcX;
private float mSavedSrcZ;
private int mFromIndex;
private int mToIndex;
private float mDxLo;
private float mDxHi;
/**
* @param delegate Data provider.
* @param dragHandlerDelegate Delegate to respond to events and results.
* @param fromView The view of the "from" tile that's being dragged.
* @param eventX The X coordinate of the initial ACTION_DOWN event on the "from" tile.
* @param eventY The Y coordinate of the same.
*/
public TileDragSession(
Delegate delegate,
TileGroup.TileDragHandlerDelegate dragHandlerDelegate,
TileView fromView,
float eventX,
float eventY) {
mDelegate = delegate;
mDragResultDelegate = dragHandlerDelegate;
mFromView = fromView;
mStartX = fixEventX(eventX);
mStartY = eventY;
}
@Initializer
public void start() {
mTileMovement = new TileMovement(mDelegate.getDraggableTileViews());
mSavedSrcX = mFromView.getX();
mSavedSrcZ = mFromView.getZ();
mFromIndex = mTileMovement.getIndexOfView(mFromView);
mToIndex = mFromIndex;
// X-margin: A dragged tile is constrained to stay in the box containing all draggable
// tiles. To soften the constraint, the X-margin specifies extra room for the dragged tile
// to travel horizontally beyond the box.
float dragXMarginPx = DRAG_X_MARGIN_RATIO * mDelegate.getTileWidthPx();
mDxLo = mTileMovement.getXLo() - mSavedSrcX - dragXMarginPx;
mDxHi = mTileMovement.getXHi() - mSavedSrcX + dragXMarginPx;
mFromView.animate().scaleX(DRAG_ACTIVE_SCALE).scaleY(DRAG_ACTIVE_SCALE).start();
// Temporarily increment Z so the "from" tile is drawn on top of other tiles.
mFromView.setZ(mSavedSrcZ + 1.0f);
}
public TileGroup.TileDragHandlerDelegate getTileDragHandlerDelegate() {
return mDragResultDelegate;
}
/**
* Updates {@link mFromView} movement on ACTION_MOVE.
*
* @param eventX The X coordinate of the The ACTION_MOVE event on the "from" tile.
*/
public void updateFromView(float eventX) {
// {@param eventX} is relative to translation X, so we need to add it back to compensate.
float rawDx = fixEventX(eventX) - mStartX;
mFromView.setTranslationX(Math.max(mDxLo, Math.min(rawDx, mDxHi)));
}
/**
* @param eventX The X coordinate of the The ACTION_MOVE event on the "from" tile.
* @param eventY The Y coordinate of same.
* @return The Euclidean distance squared from ({@link mStartX}, {@link mStartY}) to ({@param
* eventX}, {@param eventY}).
*/
public float getDragDisplacementSquared(float eventX, float eventY) {
float rawDx = fixEventX(eventX) - mStartX;
float rawDy = eventY - mStartY;
// Not using Math.hypot() since it may be slow.
return rawDx * rawDx + rawDy * rawDy;
}
/** Updates {@link mToIndex} and possibly shift background tiles on drag. */
public void updateToIndexAndAnimate() {
assert mTileMovement != null;
// Find the draggable tile that's closest to the "from" tile's current location.
// This is robust for LTR and RTL. Use linear search, which is fast enough.
int newToIndex = mTileMovement.getIndexOfViewNearestTo(mFromView.getX());
// If {@link #mToIndex} changes: Shift non-"from" tiles towards the "from" tile.
if (mToIndex != newToIndex) {
mToIndex = newToIndex;
mTileMovement.shiftBackgroundTile(mFromIndex, mToIndex);
}
}
/**
* Finishes the drag-and-drop session. If animation is in flight, also returns the {@link
* mTileMovement} instance so animation can be cancelled; otherwise returns null.
*
* @param accept Whether to accept the drag-and-drop if the "from" tile is dragged to a
* different "to" tile.
* @return A {@link Runnable} that can be called to cancel the accept / reject animation and
* action while it's in -flight. Once complete then the calling would have no effect.
*/
public @Nullable Runnable finish(boolean accept) {
mFromView.setZ(mSavedSrcZ);
mFromView.animate().scaleX(1.0f).scaleY(1.0f).start();
// Handle the case where function is called before start() is called.
if (mTileMovement == null) {
return null;
}
if (accept && mFromIndex != mToIndex) {
mTileMovement.animatedAccept(
mFromIndex,
mToIndex,
/* onAccept= */ () -> {
if (mTileMovement != null) {
TileView toView = mTileMovement.getTileViewAt(mToIndex);
mDragResultDelegate.onDragAccept(
mDelegate.getTileViewData(mFromView),
mDelegate.getTileViewData(toView));
}
});
} else {
mTileMovement.animatedReject();
}
return () -> {
if (mTileMovement != null) {
mTileMovement.cancelIfActive();
}
mFromView.setScaleX(1.0f);
mFromView.setScaleY(1.0f);
};
}
/** Helper to instantiate {@link TileMovement}, extract to method to allow testing override. */
protected TileMovement createTileMovement(List<TileView> tileViews) {
return new TileMovement(tileViews);
}
/**
* Performs correction to {@param eventX} from a fresh {@plink MotionEvent} for {@link
* #mFromView}. This is needed because the X value read is relative to
* `mFromView.getTranslationX()`, but we'd like the X relative to the container.
*/
private float fixEventX(float eventX) {
return eventX + mFromView.getTranslationX();
}
}

@ -5,9 +5,8 @@
package org.chromium.chrome.browser.suggestions.tile;
import android.util.SparseArray;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnCreateContextMenuListener;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
@ -167,7 +166,10 @@ public class TileGroup implements MostVisitedSites.Observer {
/** Delegate for handling interactions with tiles. */
public interface TileInteractionDelegate
extends OnClickListener, OnCreateContextMenuListener, View.OnLongClickListener {
extends View.OnClickListener,
View.OnCreateContextMenuListener,
View.OnLongClickListener,
View.OnTouchListener {
/**
* Set a runnable for click events on the tile. This is primarily used to track interaction
* with the tile used by feature engagement purposes.
@ -184,11 +186,57 @@ public class TileGroup implements MostVisitedSites.Observer {
void setOnRemoveRunnable(Runnable removeRunnable);
}
/** Delegate for receive intermediate events and final results of tile drag. */
public interface TileDragHandlerDelegate {
/**
* Called when the tile drag session becomes the dominant UI mode. The implementation should
* suppress competing UI, e.g., context menu.
*/
void onDragDominate();
/**
* Called when drag UI successfully produces result. The implementation should perform
* reorder and refresh UI if successful.
*
* @param fromSuggestion Data to identify the tile being dragged.
* @param toSuggestion Data to identify the tile being dropped on.
* @return Whether the operation successfully ran.
*/
boolean onDragAccept(SiteSuggestion fromSuggestion, SiteSuggestion toSuggestion);
}
/** Delegate for tile drag UI. */
public interface TileDragDelegate {
/**
* Handler for ACTION_DOWN touch event on tile. This may start a tile drag session.
*
* @param view The View of the tile receiving ACTION_DOWN.
* @param event The ACTION_DOWN event.
* @param dragHandlerDelegate Handler for drag results.
*/
void onTileTouchDown(
View view, MotionEvent event, TileDragHandlerDelegate dragHandlerDelegate);
/**
* Handler for non-ACTION_DOWN events to continue / end a tile drag session. Should be
* called if a tile drag session is live.
*/
void onSessionTileTouch(View view, MotionEvent event);
/**
* @return Whether a tile drag session is live, requiring onSessionTileTouch() to be called.
*/
boolean hasSession();
/** Forces tile drag session to end. */
void reset();
}
/** Delegate for handling interactions with custom tiles. Not tied to a particular Tile. */
public interface CustomTileModificationDelegate {
/**
* Opens the Custom Tile Edit Dialog (as "Add shortcut") to add a new Custom Tile. If add
* proceeds and is successful,refreshes the MVT.
* proceeds and is successful, refreshes the MVT.
*/
void add();
@ -210,6 +258,15 @@ public class TileGroup implements MostVisitedSites.Observer {
* refreshes the MVT.
*/
void edit(SiteSuggestion suggestion);
/**
* Searches for existing "from" and "to" Custom Tiles matching {@param fromSuggestion} and
* {@param toSuggestion}. If both are found, attempt to move "from" tile to position of the
* "to" tile, and shift everything between. If successful, refreshes the MVT.
*
* @return Whether the operation successfully ran.
*/
boolean reorder(SiteSuggestion fromSuggestion, SiteSuggestion toSuggestion);
}
/**
@ -244,6 +301,7 @@ public class TileGroup implements MostVisitedSites.Observer {
private final SuggestionsUiDelegate mUiDelegate;
private final ContextMenuManager mContextMenuManager;
private final Delegate mTileGroupDelegate;
private final TileDragDelegate mTileDragDelegate;
private final Observer mObserver;
private final TileRenderer mTileRenderer;
private final CustomTileModificationDelegate mCustomTileModificationDelegate;
@ -283,6 +341,7 @@ public class TileGroup implements MostVisitedSites.Observer {
return new TileInteractionDelegateImpl(
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mCustomTileModificationDelegate,
mPrerenderDelay,
tile,
@ -322,11 +381,13 @@ public class TileGroup implements MostVisitedSites.Observer {
SuggestionsUiDelegate uiDelegate,
ContextMenuManager contextMenuManager,
Delegate tileGroupDelegate,
TileDragDelegate tileDragDelegate,
Observer observer,
OfflinePageBridge offlinePageBridge) {
mUiDelegate = uiDelegate;
mContextMenuManager = contextMenuManager;
mTileGroupDelegate = tileGroupDelegate;
mTileDragDelegate = tileDragDelegate;
mObserver = observer;
mTileRenderer = tileRenderer;
mOfflineModelObserver = new OfflineModelObserver(offlinePageBridge);
@ -348,6 +409,7 @@ public class TileGroup implements MostVisitedSites.Observer {
boolean removalCompleted = mPendingChanges.removalUrl != null;
boolean insertionCompleted = mPendingChanges.insertionUrl == null;
boolean forceUpdate = false;
mPendingChanges.tiles = new ArrayList<>();
for (SiteSuggestion suggestion : siteSuggestions) {
@ -371,10 +433,11 @@ public class TileGroup implements MostVisitedSites.Observer {
if (mPendingChanges.customTilesIndicator) {
mPendingChanges.customTilesIndicator = false;
expectedChangeCompleted = true;
forceUpdate = true;
}
if (!mHasReceivedData || !mUiDelegate.isVisible() || expectedChangeCompleted) {
loadTiles();
loadTiles(forceUpdate);
}
}
@ -430,7 +493,7 @@ public class TileGroup implements MostVisitedSites.Observer {
*/
public void onSwitchToForeground(boolean trackLoadTask) {
if (trackLoadTask) addTask(TileTask.FETCH_DATA);
if (mPendingChanges.tiles != null) loadTiles();
if (mPendingChanges.tiles != null) loadTiles(/* forceUpdate= */ false);
if (trackLoadTask) removeTask(TileTask.FETCH_DATA);
}
@ -438,14 +501,20 @@ public class TileGroup implements MostVisitedSites.Observer {
return mTileSetupDelegate;
}
/** Loads tile data from {@link #mPendingChanges.tiles} and clears it afterwards. */
private void loadTiles() {
/**
* Loads tile data from {@link #mPendingChanges.tiles} and clears it afterwards.
*
* @param forceUpdate Flag to force an update even if tile composition remains the same. A
* particular use case is Custom Tile reordering, which keeps the set of suggestions the
* same but still requires update.
*/
private void loadTiles(boolean forceUpdate) {
assert mPendingChanges.tiles != null;
boolean isInitialLoad = !mHasReceivedData;
mHasReceivedData = true;
boolean dataChanged = isInitialLoad;
boolean dataChanged = forceUpdate || isInitialLoad;
List<Tile> personalisedTiles = mTileSections.get(TileSectionType.PERSONALIZED);
int oldPersonalisedTilesCount = personalisedTiles == null ? 0 : personalisedTiles.size();
@ -629,6 +698,15 @@ public class TileGroup implements MostVisitedSites.Observer {
mTileGroupDelegate::hasCustomLink);
}
@Override
public boolean reorder(SiteSuggestion fromSuggestion, SiteSuggestion toSuggestion) {
@Nullable Tile fromTile = findTile(fromSuggestion);
@Nullable Tile toTile = findTile(toSuggestion);
return fromTile != null
&& toTile != null
&& reorderCustomLinkAndUpdateOnSuccess(fromTile.getUrl(), toTile.getIndex());
}
private boolean addCustomLinkAndUpdateOnSuccess(String name, GURL url) {
// On success, onSiteSuggestionsAvailable() triggers.
mPendingChanges.customTilesIndicator = true;
@ -657,6 +735,15 @@ public class TileGroup implements MostVisitedSites.Observer {
mPendingChanges.customTilesIndicator = false;
}
}
private boolean reorderCustomLinkAndUpdateOnSuccess(GURL url, int newPos) {
mPendingChanges.customTilesIndicator = true;
boolean success = mTileGroupDelegate.reorderCustomLink(url, newPos);
if (!success) {
mPendingChanges.customTilesIndicator = false;
}
return success;
}
}
private class OfflineModelObserver extends SuggestionsOfflineModelObserver<Tile> {

@ -96,6 +96,12 @@ public class TileGroupDelegateImpl implements TileGroup.Delegate {
return mMostVisitedSites.hasCustomLink(keyUrl);
}
@Override
public boolean reorderCustomLink(GURL keyUrl, int newPos) {
assert !mIsDestroyed;
return mMostVisitedSites.reorderCustomLink(keyUrl, newPos);
}
// TileGroup.Delegate implementation.
@Override
@Initializer

@ -21,6 +21,7 @@ import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.native_page.ContextMenuManager;
import org.chromium.chrome.browser.native_page.ContextMenuManager.ContextMenuItemId;
import org.chromium.chrome.browser.preloading.AndroidPrerenderManager;
import org.chromium.chrome.browser.suggestions.SiteSuggestion;
import org.chromium.chrome.browser.suggestions.SuggestionsMetrics;
import org.chromium.ui.mojom.WindowOpenDisposition;
import org.chromium.url.GURL;
@ -35,9 +36,10 @@ import java.util.Objects;
class TileInteractionDelegateImpl
implements TileGroup.TileInteractionDelegate,
ContextMenuManager.Delegate,
View.OnTouchListener {
TileGroup.TileDragHandlerDelegate {
private final ContextMenuManager mContextMenuManager;
private final TileGroup.Delegate mTileGroupDelegate;
private final TileGroup.TileDragDelegate mTileDragDelegate;
private final TileGroup.CustomTileModificationDelegate mCustomTileModificationDelegate;
private final int mPrerenderDelay;
private final Tile mTile;
@ -45,16 +47,16 @@ class TileInteractionDelegateImpl
private @Nullable Runnable mOnClickRunnable;
private @Nullable Runnable mOnRemoveRunnable;
private @Nullable Long mTouchTimer;
private @Nullable Long mTouchTime;
private @Nullable CancelableRunnable mPrerenderRunnable;
private @Nullable GURL mPrerenderedUrl;
private @Nullable GURL mScheduldedPrerenderingUrl;
private void maybeRecordTouchDuration(boolean taken) {
if (mTouchTimer == null) return;
if (mTouchTime == null) return;
long duration = TimeUtils.elapsedRealtimeMillis() - mTouchTimer;
mTouchTimer = null;
long duration = TimeUtils.elapsedRealtimeMillis() - mTouchTime;
mTouchTime = null;
RecordHistogram.recordLongTimesHistogram(
taken
? "Prerender.Experimental.NewTabPage.TouchDuration.Taken"
@ -65,12 +67,14 @@ class TileInteractionDelegateImpl
public TileInteractionDelegateImpl(
ContextMenuManager contextMenuManager,
TileGroup.Delegate tileGroupDelegate,
TileGroup.TileDragDelegate tileDragDelegate,
TileGroup.CustomTileModificationDelegate customTileModificationDelegate,
int prerenderDelay,
Tile tile,
View view) {
mContextMenuManager = contextMenuManager;
mTileGroupDelegate = tileGroupDelegate;
mTileDragDelegate = tileDragDelegate;
mCustomTileModificationDelegate = customTileModificationDelegate;
mPrerenderDelay = prerenderDelay;
mTile = tile;
@ -80,11 +84,13 @@ class TileInteractionDelegateImpl
mTileGroupDelegate.initAndroidPrerenderManager(mAndroidPrerenderManager);
}
// TileGroup.TileInteractionDelegate => OnClickListener implementation.
@Override
public void onClick(View view) {
maybeRecordTouchDuration(true);
SuggestionsMetrics.recordTileTapped();
mTileDragDelegate.reset();
if (mOnClickRunnable != null) mOnClickRunnable.run();
mTileGroupDelegate.openMostVisitedItem(WindowOpenDisposition.CURRENT_TAB, mTile);
}
@ -137,21 +143,59 @@ class TileInteractionDelegateImpl
mScheduldedPrerenderingUrl = null;
}
// TileGroup.TileInteractionDelegate => View.OnCreateContextMenuListener implementation.
@Override
public void onCreateContextMenu(
ContextMenu contextMenu, View view, ContextMenuInfo contextMenuInfo) {
if (ChromeFeatureList.isEnabled(ChromeFeatureList.TILE_CONTEXT_MENU_REFACTOR)) return;
mContextMenuManager.createContextMenu(contextMenu, view, this);
}
// TileGroup.TileInteractionDelegate => View.OnLongClickListener implementation.
@Override
public boolean onLongClick(View view) {
if (!ChromeFeatureList.isEnabled(ChromeFeatureList.TILE_CONTEXT_MENU_REFACTOR)) {
return false;
}
return mContextMenuManager.showListContextMenu(view, this);
}
// TileGroup.TileInteractionDelegate => View.OnTouchListener implementation.
@Override
@SuppressLint("ClickableViewAccessibility")
public boolean onTouch(View view, MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mTouchTimer = TimeUtils.elapsedRealtimeMillis();
mTouchTime = TimeUtils.elapsedRealtimeMillis();
maybePrerender(mTile.getUrl());
}
if (event.getAction() == MotionEvent.ACTION_CANCEL) {
} else if (event.getAction() == MotionEvent.ACTION_CANCEL) {
maybeRecordTouchDuration(false);
cancelPrerender();
}
// Handle tile drag-and-drop separately.
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mTileDragDelegate.onTileTouchDown(view, event, this);
} else if (mTileDragDelegate.hasSession()) {
mTileDragDelegate.onSessionTileTouch(view, event);
}
return false;
}
// TileGroup.TileInteractionDelegate implementation.
@Override
public void setOnClickRunnable(Runnable clickRunnable) {
mOnClickRunnable = clickRunnable;
}
@Override
public void setOnRemoveRunnable(Runnable removeRunnable) {
mOnRemoveRunnable = removeRunnable;
}
// ContextMenuManager.Delegate implementation.
@Override
public void openItem(int windowDisposition) {
mTileGroupDelegate.openMostVisitedItem(windowDisposition, mTile);
@ -212,31 +256,15 @@ class TileInteractionDelegateImpl
@Override
public void onContextMenuCreated() {}
// TileGroup.TileDragHandlerDelegate implementation.
@Override
public void onCreateContextMenu(
ContextMenu contextMenu, View view, ContextMenuInfo contextMenuInfo) {
if (ChromeFeatureList.isEnabled(ChromeFeatureList.TILE_CONTEXT_MENU_REFACTOR)) return;
mContextMenuManager.createContextMenu(contextMenu, view, this);
public void onDragDominate() {
mContextMenuManager.hideListContextMenu();
}
@Override
public boolean onLongClick(View view) {
if (!ChromeFeatureList.isEnabled(ChromeFeatureList.TILE_CONTEXT_MENU_REFACTOR)) {
return false;
}
return mContextMenuManager.showListContextMenu(view, this);
}
@Override
public void setOnClickRunnable(Runnable clickRunnable) {
mOnClickRunnable = clickRunnable;
}
@Override
public void setOnRemoveRunnable(Runnable removeRunnable) {
mOnRemoveRunnable = removeRunnable;
public boolean onDragAccept(SiteSuggestion fromSuggestion, SiteSuggestion toSuggestion) {
return mCustomTileModificationDelegate.reorder(fromSuggestion, toSuggestion);
}
boolean isCustomizationItemSupported(boolean matchIsCustomLink) {

@ -0,0 +1,195 @@
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.suggestions.tile;
import android.view.ViewPropertyAnimator;
import org.chromium.build.annotations.NullMarked;
import org.chromium.build.annotations.Nullable;
import org.chromium.components.browser_ui.widget.tile.TileView;
import java.util.ArrayList;
import java.util.List;
/**
* Manages transient movement and animation of a row of {@link TileView} instances that are being
* reordered under drag-and-drop UI. When tiles move, only their (visual) locations are affected,
* and not their order within the parent container. Once tile movement has been reject or accepted,
* all positions are restored; a refresh is needed to actually move tiles.
*/
@NullMarked
public class TileMovement {
private final List<TileView> mTileViews;
private final List<Float> mOriginalX;
private final List<@Nullable ViewPropertyAnimator> mAnimators;
private boolean mIsActive;
/**
* @param tileViews A non-empty row of tiles to manage. The X-coordinates are assumed to ascend
* (LTR) or descend (RTL).
*/
TileMovement(List<TileView> tileViews) {
assert !tileViews.isEmpty();
mTileViews = tileViews;
mOriginalX = new ArrayList<Float>();
mAnimators = new ArrayList<@Nullable ViewPropertyAnimator>();
for (TileView tileView : mTileViews) {
float x = tileView.getX();
mOriginalX.add(x);
mAnimators.add(null);
}
mIsActive = true;
}
/**
* @return The {@link TileView} at {@param index}.
*/
public TileView getTileViewAt(int index) {
return mTileViews.get(index);
}
/**
* @return Index of {@param view} existing in {@link #mTileViews}.
*/
public int getIndexOfView(TileView view) {
int index = mTileViews.indexOf(view);
assert index >= 0;
return index;
}
/**
* @return Index of the {@link #mTileViews} element whose X-coordinate is nearest to {@param x},
* preferring lower index on a tie.
*/
public int getIndexOfViewNearestTo(float x) {
int n = mTileViews.size();
int bestIndex = 0;
float bestDist = Math.abs(mOriginalX.get(0) - x);
for (int i = 1; i < n; ++i) {
float dist = Math.abs(mOriginalX.get(i) - x);
if (bestDist > dist) {
bestDist = dist;
bestIndex = i;
}
}
return bestIndex;
}
/**
* @return The minimum X-coordinate among managed tiles.
*/
public float getXLo() {
return Math.min(mOriginalX.get(0), mOriginalX.get(mOriginalX.size() - 1));
}
/**
* @return The maximum X-coordinate among managed tiles.
*/
public float getXHi() {
return Math.max(mOriginalX.get(0), mOriginalX.get(mOriginalX.size() - 1));
}
/**
* Changes the (visual) location of the tile at {@param index} to the original location of tile
* at {@param newIndex}. Once move completes (possibly after animation, as determined by {@param
* isAnimated}), runs {@param onEnd} if non-null. Replaces the previous tile animation, and
* prevents the previously specified {@param onEnd} from running.
*/
public void moveTile(int index, int newIndex, boolean isAnimated, @Nullable Runnable onEnd) {
TileView tileView = mTileViews.get(index);
float x = mOriginalX.get(newIndex);
ViewPropertyAnimator oldAnimator = mAnimators.get(index);
if (oldAnimator != null) {
oldAnimator.cancel();
mAnimators.set(index, null);
}
if (isAnimated) {
ViewPropertyAnimator animator = tileView.animate();
mAnimators.set(index, animator);
if (onEnd != null) {
animator.withEndAction(onEnd);
}
animator.x(x).start();
} else {
tileView.setX(x);
if (onEnd != null) {
onEnd.run();
}
}
}
/**
* Given that tile at {@param fromIndex} is moved to {@param toIndex}, shifts the latter and all
* tiles in between towards {@param fromIndex}, and resets all other tiles, with animation that
* overrides existing animations.
*/
public void shiftBackgroundTile(int fromIndex, int toIndex) {
int n = mTileViews.size();
// Shift affected tiles if {@link #toIndex} < {@link #fromIndex}.
for (int i = toIndex; i < fromIndex; ++i) {
moveTile(i, i + 1, /* isAnimated= */ true, /* onEnd= */ null);
}
// Shift affected tiles if {@link #toIndex} > {@link #fromIndex}.
for (int i = toIndex; i > fromIndex; --i) {
moveTile(i, i - 1, /* isAnimated= */ true, /* onEnd= */ null);
}
// Reset tiles that are unaffected / no longer affected.
for (int i = Math.min(fromIndex, toIndex) - 1; i >= 0; --i) {
moveTile(i, i, /* isAnimated= */ true, /* onEnd= */ null);
}
for (int i = Math.max(fromIndex, toIndex) + 1; i < n; ++i) {
moveTile(i, i, /* isAnimated= */ true, /* onEnd= */ null);
}
}
/** Stops all pending animations and restores original tile locations without animation. */
public void cancelIfActive() {
if (mIsActive) {
restoreTiles(/* isAnimated= */ false);
mIsActive = false;
}
}
/**
* Accepts tile movement from {@param fromIndex} to {@param toIndex}, starting with animation
* for aesthetics. The animation can still be cancelled by calling cancel(). When animation
* finishes, runs {@param onAccept}.
*/
public void animatedAccept(int fromIndex, int toIndex, Runnable onAccept) {
// Animate the "from" tile moving to "to" tile, which can be cancelled via cancelIfActive().
moveTile(
fromIndex,
toIndex,
/* isAnimated= */ true,
() -> {
// Animation completes: Disable cancelIfActive(), restore all tile positions,
// then run {@param onAccept}. Note that restore is done regardless of whether
// the run fails or succeeds. That's because {@link TileVIew} visual changes
// are transient, and need to be undone, especially that they may be reused.
// Next, would restore cause "glitch", i.e., tiles temporarily jump back, before
// being re-rendered into the desired state? We assume this won't happen
// (perhaps since @{param onAccept} is eager), or that any effect is negligible
// and not worth fixing for now.
mIsActive = false;
restoreTiles(/* isAnimated= */ false);
onAccept.run();
});
}
/** Restores original tile locations, with animation. */
public void animatedReject() {
// Restore tiles with animation. This can get cancelled via cancelIfActive(). And if the
// animation completes, cancelIfActive() can still be called -- but this is no-op anyway.
restoreTiles(/* isAnimated= */ true);
}
/** Restores original tile locations, with {@param isAnimated} specified. */
private void restoreTiles(boolean isAnimated) {
for (int i = 0; i < mTileViews.size(); ++i) {
moveTile(i, i, isAnimated, null);
}
}
}

@ -74,6 +74,7 @@ public class TileGroupUnitTest {
@Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
@Mock private TileGroup.Observer mTileGroupObserver;
@Mock private TileGroup.Delegate mTileGroupDelegate;
@Mock private TileGroup.TileDragDelegate mTileDragDelegate;
@Mock private SuggestionsUiDelegate mSuggestionsUiDelegate;
@Mock private ContextMenuManager mContextMenuManager;
@Mock private OfflinePageBridge mOfflinePageBridge;
@ -125,6 +126,7 @@ public class TileGroupUnitTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -151,6 +153,7 @@ public class TileGroupUnitTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -259,6 +262,7 @@ public class TileGroupUnitTest {
uiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -281,6 +285,7 @@ public class TileGroupUnitTest {
uiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -331,6 +336,7 @@ public class TileGroupUnitTest {
uiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -362,6 +368,7 @@ public class TileGroupUnitTest {
uiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -389,6 +396,7 @@ public class TileGroupUnitTest {
uiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -423,6 +431,7 @@ public class TileGroupUnitTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);
@ -585,6 +594,7 @@ public class TileGroupUnitTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.startObserving(MAX_TILES_TO_FETCH);

@ -352,6 +352,7 @@ public class MostVisitedMediatorUnitTest {
private void createMediator(boolean isTablet) {
mMvTilesLayout = Mockito.mock(MostVisitedTilesLayout.class);
when(mMvTilesLayout.getResources()).thenReturn(mResources);
when(mMvTilesLayout.getChildCount()).thenReturn(1);
when(mMvTilesLayout.getChildAt(0)).thenReturn(mTileView);
when(mMvTilesLayout.getTileCount()).thenReturn(1);

@ -53,6 +53,7 @@ public class TileInteractionDelegateTest {
SuggestionsUiDelegate uiDelegate,
ContextMenuManager contextMenuManager,
Delegate tileGroupDelegate,
TileDragDelegate tileDragDelegate,
Observer observer,
OfflinePageBridge offlinePageBridge) {
super(
@ -60,6 +61,7 @@ public class TileInteractionDelegateTest {
uiDelegate,
contextMenuManager,
tileGroupDelegate,
tileDragDelegate,
observer,
offlinePageBridge);
}
@ -81,6 +83,7 @@ public class TileInteractionDelegateTest {
@Mock SuggestionsUiDelegate mSuggestionsUiDelegate;
@Mock ContextMenuManager mContextMenuManager;
@Mock TileGroup.Delegate mTileGroupDelegate;
@Mock TileGroup.TileDragDelegate mTileDragDelegate;
@Mock OfflinePageBridge mOfflinePageBridge;
@Mock private Runnable mSnapshotTileGridChangedRunnable;
@Mock private Runnable mTileCountChangedRunnable;
@ -118,6 +121,7 @@ public class TileInteractionDelegateTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.onIconMadeAvailable(new GURL("https://example.com"));
@ -150,6 +154,7 @@ public class TileInteractionDelegateTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.onIconMadeAvailable(new GURL("https://example.com"));
@ -173,6 +178,7 @@ public class TileInteractionDelegateTest {
mSuggestionsUiDelegate,
mContextMenuManager,
mTileGroupDelegate,
mTileDragDelegate,
mTileGroupObserver,
mOfflinePageBridge);
tileGroup.setTileForTesting(mTile);

@ -241,6 +241,12 @@ jboolean MostVisitedSitesBridge::HasCustomLink(JNIEnv* env,
return most_visited_->HasCustomLink(key_url);
}
jboolean MostVisitedSitesBridge::ReorderCustomLink(JNIEnv* env,
const GURL& key_url,
jint new_pos) {
return most_visited_->ReorderCustomLink(key_url, new_pos);
}
void MostVisitedSitesBridge::AddOrRemoveBlockedUrl(
JNIEnv* env,
const JavaParamRef<jobject>& obj,

@ -55,6 +55,8 @@ class MostVisitedSitesBridge {
jboolean HasCustomLink(JNIEnv* env, const GURL& key_url);
jboolean ReorderCustomLink(JNIEnv* env, const GURL& key_url, jint new_pos);
void AddOrRemoveBlockedUrl(JNIEnv* env,
const base::android::JavaParamRef<jobject>& obj,
const base::android::JavaParamRef<jobject>& j_url,

@ -58,6 +58,12 @@ public class FakeMostVisitedSites implements MostVisitedSites {
return false;
}
@Override
public boolean reorderCustomLink(GURL keyUrl, int newPos) {
// TODO (crbug.com/397421764): Implement when needed by tests.
return false;
}
// MostVisitedSites implementation.
@Override
public void destroy() {}

@ -130,4 +130,9 @@ public class TileView extends FrameLayout {
mOnFocusViaSelectionListener.run();
}
}
/** Returns whether the tile can be moved using drag-and-drop. */
public boolean isDraggable() {
return false;
}
}