launcher 是android系统桌面主界面App, 是用户开机后看到的第一个界面。主要有以下功能:
一.应用管理。
1.应用图标展示。
2.启动APP
3.卸载应用
4.应用排序。支持应用拖拽
二.桌面页面管理.
左右划动切换不同桌面页
三 widget 小组件
加载删除桌面小组件
四.最近任务
下面从0开始实现一个launcher应用,实现应用管理和桌面页面管理的功能。

一.给出页面布局。
desktop_main.xml
<?xml version="1.0" encoding="utf-8"?>
<com.example.mycarlauncher.widget.DesktopRootLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<com.example.mycarlauncher.widget.Workspace
android:id="@+id/workspace"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</com.example.mycarlauncher.widget.DesktopRootLayout>
二. 桌面主Activity.
DesktopActivity
package com.example.mycarlauncher;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.Toast;
import com.example.mycarlauncher.data.DesktopItem;
import com.example.mycarlauncher.widget.DesktopPage;
import com.example.mycarlauncher.data.DesktopRepository;
import com.example.mycarlauncher.widget.DesktopRootLayout;
import com.example.mycarlauncher.widget.IconView;
import com.example.mycarlauncher.widget.Workspace;
import java.util.List;
public class DesktopActivity extends Activity {
private DesktopRootLayout rootLayout;
private Workspace workspace;
private DesktopRepository repository;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView( R.layout.activity_desktop );
repository = new DesktopRepository(this);
rootLayout = findViewById(R.id.root_container);
workspace = findViewById(R.id.workspace );
rootLayout.setWorkspace( workspace);
rootLayout.setRepository( repository );
loadDesktopItems();
}
private void loadDesktopItems() {
List<DesktopItem> items =
repository.loadOrCreateDesktopItems(this);
int pageCount = calculatePageCount(items);
for (int i = 0; i < pageCount; i++) {
DesktopPage page = new DesktopPage(
this,
DesktopRepository.COUNT_X,
DesktopRepository.COUNT_Y
);
workspace.addView(page);
}
PackageManager pm = getPackageManager();
for (DesktopItem item : items) {
if (item.screenId < 0
|| item.screenId >= workspace.getPageCount()) {
continue;
}
DesktopPage page = workspace.getPageAt(item.screenId);
IconView iconView = new IconView(this);
iconView.setTag(item);
try {
ComponentName componentName = new ComponentName(
item.packageName,
item.className
);
Intent queryIntent = new Intent(Intent.ACTION_MAIN);
queryIntent.addCategory(Intent.CATEGORY_LAUNCHER);
queryIntent.setComponent(componentName);
ResolveInfo resolveInfo = pm.resolveActivity(
queryIntent,
PackageManager.MATCH_DEFAULT_ONLY
);
if (resolveInfo == null) {
continue;
}
CharSequence label = resolveInfo.loadLabel(pm);
Drawable icon = resolveInfo.loadIcon(pm);
iconView.bind(
label == null ? item.packageName : label.toString(),
icon
);
// 普通点击:启动应用。
iconView.setOnClickListener(v -> launchApp(item));
// 长按:开始桌面图标拖动。
iconView.setOnLongClickListener(v -> {
rootLayout.startDrag(iconView);
return true;
});
page.addView(iconView);
} catch (Exception e) {
e.printStackTrace();
}
}
}
private int calculatePageCount(List<DesktopItem> items) {
int maxScreenId = 0;
for (DesktopItem item : items) {
maxScreenId = Math.max(maxScreenId, item.screenId);
}
return maxScreenId + 1;
}
private void launchApp(DesktopItem item) {
try {
ComponentName componentName = new ComponentName(
item.packageName,
item.className
);
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_LAUNCHER);
intent.setComponent(componentName);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
} catch (Exception e) {
Toast.makeText(
this,
"无法启动:" + item.packageName,
Toast.LENGTH_SHORT
).show();
}
}
}

支持拖拽应用

拖拽到最右侧,能创建新的页面。

从上面的xml 能看出主布局容器是
DesktopRootLayout
package com.example.mycarlauncher.widget;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import com.example.mycarlauncher.data.DesktopItem;
import com.example.mycarlauncher.data.DesktopRepository;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
public class DesktopRootLayout extends FrameLayout {
/*
* 手指进入屏幕左右边缘的范围。
* 在该范围内停留一段时间后触发翻页。
*/
private static final int EDGE_ZONE_DP = 96;
/*
* 手指在边缘停留多久后翻页。
*/
private static final long PAGE_SWITCH_DELAY = 550L;
private Workspace workspace;
private DesktopRepository repository;
/*
* 当前正在进行的拖拽信息。
* 为 null 时表示当前没有拖拽操作。
*/
private DragInfo dragInfo;
private final Handler handler = new Handler(Looper.getMainLooper());
/*
* 自动翻页相关状态。
*/
private Runnable pageSwitchRunnable;
private int pendingTargetPage = -1;
/*
* 当前正在显示目标格子预览的页面。
*/
private DesktopPage previewPage;
/*
* 当前拖动坐标。
*
* raw 坐标:相对于整个屏幕;
* root 坐标:相对于 DesktopRootLayout。
*/
private float lastRawX;
private float lastRawY;
private float lastRootX;
private float lastRootY;
/*
* 第四阶段:当前预览落点是否可放置。
*
* 可放置的条件:
* 1. 目标格子为空;
* 2. 或目标格子被占用,但当前页可以自动重排。
*/
private boolean previewCanDrop = false;
/*
* 当前预览期间,被自动让位、发生位置变化的图标。
*
* 拖拽松手成功后,需要将这些图标连同被拖动图标一起写入数据库。
*/
private List<DesktopItem> previewReorderedItems =
new ArrayList<>();
/*
* 保存拖拽开始前,所有图标的初始位置。
*
* 自动重排仅是预览,不应立即写数据库;
* 用户取消拖动、落到无效位置或拖到其他位置时,
* 需要基于此数据恢复。
*/
private final Map<DesktopItem, CellPosition> originalPositions =
new IdentityHashMap<>();
/*
* 当前一次停留在右侧边缘期间,是否已经创建过新页。
*
* 防止用户一直把手指放在最右边缘时,
* 自动连续创建很多空页面。
*/
private boolean createdPageDuringCurrentRightEdge = false;
public DesktopRootLayout(Context context) {
this(context, null);
}
public DesktopRootLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public DesktopRootLayout(
Context context,
AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
setClipChildren(false);
setClipToPadding(false);
}
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
public void setRepository(DesktopRepository repository) {
this.repository = repository;
}
/**
* IconView 长按后调用,开始拖动。
*/
public void startDrag(IconView iconView) {
if (dragInfo != null || workspace == null) {
return;
}
DesktopItem item = (DesktopItem) iconView.getTag();
if (item == null) {
return;
}
if (!(iconView.getParent() instanceof DesktopPage)) {
return;
}
DesktopPage sourcePage = (DesktopPage) iconView.getParent();
DragInfo info = new DragInfo();
info.iconView = iconView;
info.item = item;
info.sourcePage = sourcePage;
info.sourceScreenId = item.screenId;
info.sourceCellX = item.cellX;
info.sourceCellY = item.cellY;
info.dragView = createDragView(iconView);
/*
* 拖动开始前保存所有图标的位置。
*/
snapshotOriginalPositions();
/*
* 原始图标不移除,只隐藏。
*
* 这样:
* 1. 可保留原父布局;
* 2. 拖动取消时方便恢复;
* 3. 拖动中可将它视作一个空位。
*/
iconView.setVisibility(INVISIBLE);
/*
* DragView 被添加到最顶层,始终显示在所有图标上方。
*/
addView(info.dragView);
dragInfo = info;
}
/**
* 为原始图标生成 Bitmap 快照。
*
* 拖动过程中移动的是该快照,
* 而不是页面内的真实 IconView。
*/
private ImageView createDragView(View source) {
int width = source.getWidth();
int height = source.getHeight();
Bitmap bitmap = Bitmap.createBitmap(
width,
height,
Bitmap.Config.ARGB_8888
);
Canvas canvas = new Canvas(bitmap);
source.draw(canvas);
ImageView dragView = new ImageView(getContext());
dragView.setImageBitmap(bitmap);
dragView.setAlpha(0.92f);
dragView.setScaleX(1.08f);
dragView.setScaleY(1.08f);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
width,
height
);
dragView.setLayoutParams(params);
/*
* 让 DragView 初始位置与原图标重合。
*/
int[] sourceLocation = new int[2];
source.getLocationOnScreen(sourceLocation);
int[] rootLocation = new int[2];
getLocationOnScreen(rootLocation);
dragView.setX(sourceLocation[0] - rootLocation[0]);
dragView.setY(sourceLocation[1] - rootLocation[1]);
return dragView;
}
/**
* 长按启动拖动后,RootLayout 拦截后续 Move / Up / Cancel。
*
* 这样事件不会继续被 Workspace 或 IconView 消费。
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (dragInfo == null) {
return false;
}
int action = event.getActionMasked();
return action == MotionEvent.ACTION_MOVE
|| action == MotionEvent.ACTION_UP
|| action == MotionEvent.ACTION_CANCEL;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (dragInfo == null) {
return super.onTouchEvent(event);
}
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
onDragMove(event);
return true;
case MotionEvent.ACTION_UP:
onDrop(event);
return true;
case MotionEvent.ACTION_CANCEL:
cancelDrag();
return true;
default:
return true;
}
}
private void onDragMove(MotionEvent event) {
if (dragInfo == null) {
return;
}
lastRawX = event.getRawX();
lastRawY = event.getRawY();
lastRootX = event.getX();
lastRootY = event.getY();
ImageView dragView = dragInfo.dragView;
/*
* 让悬浮图标中心大致跟随手指。
*/
dragView.setX(lastRootX - dragView.getWidth() / 2f);
dragView.setY(lastRootY - dragView.getHeight() / 2f);
/*
* 根据当前位置显示目标格子和自动让位预览。
*/
updateDropPreview(lastRawX, lastRawY);
/*
* 检查是否进入左右边缘。
*/
checkEdgeAndSchedulePageSwitch(lastRootX);
}
private DesktopPage getCurrentTargetPage() {
int currentPage = workspace.getCurrentPage();
return workspace.getPageAt(currentPage);
}
/**
* 更新落点预览。
*
* 每次移动时:
* 1. 先恢复上一次预览造成的临时重排;
* 2. 根据当前手指位置确定目标格子;
* 3. 空位直接显示绿色;
* 4. 占用时尝试自动让位;
* 5. 无法让位时显示红色。
*/
private void updateDropPreview(float rawX, float rawY) {
if (dragInfo == null || workspace == null) {
return;
}
/*
* 恢复旧预览状态,防止之前被移动的图标残留在错误位置。
*/
restoreTemporaryReorder();
DesktopPage targetPage = getCurrentTargetPage();
if (previewPage != null && previewPage != targetPage) {
previewPage.clearDropPreview();
}
previewPage = targetPage;
int[] pageLocation = new int[2];
targetPage.getLocationOnScreen(pageLocation);
float localX = rawX - pageLocation[0];
float localY = rawY - pageLocation[1];
int[] targetCell = targetPage.pointToCell(localX, localY);
int targetCellX = targetCell[0];
int targetCellY = targetCell[1];
boolean occupied = targetPage.isCellOccupied(
targetCellX,
targetCellY,
dragInfo.iconView
);
previewCanDrop = false;
previewReorderedItems = new ArrayList<>();
/*
* 目标位置本来就是空的,直接可放置。
*/
if (!occupied) {
previewCanDrop = true;
} else {
/*
* 目标位置已有图标时,尝试自动重排。
*/
boolean canReorder = targetPage.canReorder(
targetCellX,
targetCellY,
dragInfo.iconView
);
if (canReorder) {
List<DesktopItem> changedItems =
targetPage.reorderForDrop(
targetCellX,
targetCellY,
dragInfo.iconView
);
if (changedItems != null) {
previewReorderedItems = changedItems;
previewCanDrop = true;
}
}
}
targetPage.setDropPreview(
targetCellX,
targetCellY,
previewCanDrop
);
}
/**
* 检查手指是否位于页面左右边缘。
*/
private void checkEdgeAndSchedulePageSwitch(float rootX) {
int edgeWidth = dp(EDGE_ZONE_DP);
int width = getWidth();
if (rootX <= edgeWidth) {
/*
* 已离开右边缘,允许后续右边缘再次创建页面。
*/
createdPageDuringCurrentRightEdge = false;
schedulePageSwitch(-1);
} else if (rootX >= width - edgeWidth) {
/*
* 如果本次右边缘停留已经创建过新页,
* 不重复创建。
*/
if (!createdPageDuringCurrentRightEdge) {
schedulePageSwitch(1);
}
} else {
/*
* 手指离开边缘后,取消待执行翻页任务。
*/
createdPageDuringCurrentRightEdge = false;
cancelPageSwitch();
}
}
/**
* direction:
*
* -1:上一页
* 1:下一页
*/
private void schedulePageSwitch(int direction) {
if (dragInfo == null || workspace == null) {
return;
}
int currentPage = workspace.getCurrentPage();
int pageCount = workspace.getPageCount();
int targetPage = currentPage + direction;
/*
* 第一页继续向左,不允许翻页。
*/
if (direction < 0 && targetPage < 0) {
cancelPageSwitch();
return;
}
/*
* 当前在最后一页并继续向右:
*
* targetPage == pageCount
*
* 此时不存在该页面,因此创建一个新页面。
*/
boolean needCreateNewPage =
direction > 0 && targetPage == pageCount;
/*
* 避免异常页码。
*/
if (targetPage > pageCount) {
cancelPageSwitch();
return;
}
/*
* 同一目标页已有待执行任务时,不重复 post。
*/
if (pendingTargetPage == targetPage) {
return;
}
cancelPageSwitch();
pendingTargetPage = targetPage;
final int finalTargetPage = targetPage;
final boolean finalNeedCreateNewPage = needCreateNewPage;
pageSwitchRunnable = () -> {
if (dragInfo == null || workspace == null) {
return;
}
pendingTargetPage = -1;
pageSwitchRunnable = null;
/*
* 最后一页向右拖动时,创建新页面。
*/
if (finalNeedCreateNewPage) {
workspace.addNewPage(
getContext(),
DesktopRepository.COUNT_X,
DesktopRepository.COUNT_Y
);
createdPageDuringCurrentRightEdge = true;
}
/*
* 拖拽期间立即切页,避免动画状态下坐标换算不稳定。
*/
workspace.snapToPageImmediately(finalTargetPage);
/*
* 在新页面中重新计算落点。
*/
updateDropPreview(lastRawX, lastRawY);
/*
* 普通已有页面之间可以持续翻页;
* 新建页面后停止自动继续创建。
*/
if (!finalNeedCreateNewPage) {
checkEdgeAndSchedulePageSwitch(lastRootX);
}
};
handler.postDelayed(pageSwitchRunnable, PAGE_SWITCH_DELAY);
}
private void cancelPageSwitch() {
if (pageSwitchRunnable != null) {
handler.removeCallbacks(pageSwitchRunnable);
}
pageSwitchRunnable = null;
pendingTargetPage = -1;
}
/**
* 用户松手后提交最终落点。
*/
private void onDrop(MotionEvent event) {
if (dragInfo == null) {
return;
}
cancelPageSwitch();
lastRawX = event.getRawX();
lastRawY = event.getRawY();
/*
* 确保最后一次落点也会执行重排计算。
*/
updateDropPreview(lastRawX, lastRawY);
if (!previewCanDrop) {
restoreToSource();
finishDrag();
return;
}
DesktopPage targetPage = getCurrentTargetPage();
int[] pageLocation = new int[2];
targetPage.getLocationOnScreen(pageLocation);
float localX = lastRawX - pageLocation[0];
float localY = lastRawY - pageLocation[1];
int[] targetCell = targetPage.pointToCell(localX, localY);
moveToTargetCell(
targetPage,
targetCell[0],
targetCell[1]
);
finishDrag();
}
/**
* 将当前拖动图标放到目标页面和目标格子。
*/
private void moveToTargetCell(
DesktopPage targetPage,
int targetCellX,
int targetCellY
) {
IconView iconView = dragInfo.iconView;
DesktopPage sourcePage = dragInfo.sourcePage;
DesktopItem draggedItem = dragInfo.item;
int targetScreenId = workspace.getCurrentPage();
/*
* 跨页面移动时,修改 IconView 的父容器。
*/
if (sourcePage != targetPage) {
sourcePage.removeView(iconView);
targetPage.addView(
iconView,
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
);
}
draggedItem.screenId = targetScreenId;
draggedItem.cellX = targetCellX;
draggedItem.cellY = targetCellY;
iconView.setVisibility(VISIBLE);
sourcePage.clearDropPreview();
targetPage.clearDropPreview();
sourcePage.requestLayout();
targetPage.requestLayout();
/*
* 写数据库:
*
* 1. 被拖动图标;
* 2. 自动让位时发生移动的图标。
*/
List<DesktopItem> changedItems = new ArrayList<>(
previewReorderedItems
);
changedItems.add(draggedItem);
if (repository != null) {
repository.updateItemPositions(changedItems);
}
}
/**
* 拖动取消、目标位置无效时,恢复原始状态。
*/
private void restoreToSource() {
restoreTemporaryReorder();
DesktopItem item = dragInfo.item;
item.screenId = dragInfo.sourceScreenId;
item.cellX = dragInfo.sourceCellX;
item.cellY = dragInfo.sourceCellY;
dragInfo.iconView.setVisibility(VISIBLE);
dragInfo.sourcePage.clearDropPreview();
dragInfo.sourcePage.requestLayout();
}
/**
* 保存所有图标的初始位置。
*/
private void snapshotOriginalPositions() {
originalPositions.clear();
if (workspace == null) {
return;
}
for (int pageIndex = 0;
pageIndex < workspace.getPageCount();
pageIndex++) {
DesktopPage page = workspace.getPageAt(pageIndex);
for (int i = 0; i < page.getChildCount(); i++) {
View child = page.getChildAt(i);
DesktopItem item = (DesktopItem) child.getTag();
if (item == null) {
continue;
}
originalPositions.put(
item,
new CellPosition(item.cellX, item.cellY)
);
}
}
}
/**
* 恢复拖拽预览造成的临时重排。
*
* 此处不操作数据库。
*/
private void restoreTemporaryReorder() {
if (originalPositions.isEmpty()) {
return;
}
for (Map.Entry<DesktopItem, CellPosition> entry
: originalPositions.entrySet()) {
DesktopItem item = entry.getKey();
CellPosition position = entry.getValue();
item.cellX = position.cellX;
item.cellY = position.cellY;
}
if (workspace != null) {
for (int pageIndex = 0;
pageIndex < workspace.getPageCount();
pageIndex++) {
workspace.getPageAt(pageIndex).requestLayout();
}
}
previewReorderedItems = new ArrayList<>();
}
private void cancelDrag() {
if (dragInfo == null) {
return;
}
cancelPageSwitch();
restoreToSource();
finishDrag();
}
/**
* 清理拖动临时状态。
*/
private void finishDrag() {
cancelPageSwitch();
if (previewPage != null) {
previewPage.clearDropPreview();
}
previewPage = null;
previewCanDrop = false;
previewReorderedItems.clear();
originalPositions.clear();
createdPageDuringCurrentRightEdge = false;
if (dragInfo != null && dragInfo.dragView != null) {
removeView(dragInfo.dragView);
}
dragInfo = null;
}
private int dp(int value) {
return (int) (
value * getResources().getDisplayMetrics().density + 0.5f
);
}
private static class CellPosition {
final int cellX;
final int cellY;
CellPosition(int cellX, int cellY) {
this.cellX = cellX;
this.cellY = cellY;
}
}
private static class DragInfo {
IconView iconView;
DesktopItem item;
DesktopPage sourcePage;
int sourceScreenId;
int sourceCellX;
int sourceCellY;
ImageView dragView;
}
}
主布局容器中放的是 workspace
package com.example.mycarlauncher.widget;
import android.animation.ValueAnimator;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
public class Workspace extends ViewGroup {
/*
* 当前显示的页面下标。
*
* screenId 与页面 index 在当前实现中保持一致:
*
* 第 0 页 -> screenId = 0
* 第 1 页 -> screenId = 1
* 第 N 页 -> screenId = N
*/
private int currentPage = 0;
private float downX;
private float lastX;
private final int touchSlop;
private ValueAnimator scrollAnimator;
public Workspace(Context context) {
this(context, null);
}
public Workspace(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public Workspace(
Context context,
AttributeSet attrs,
int defStyleAttr) {
super(context, attrs, defStyleAttr);
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
setClipChildren(false);
setClipToPadding(false);
}
public int getCurrentPage() {
return currentPage;
}
public int getPageCount() {
return getChildCount();
}
public DesktopPage getPageAt(int pageIndex) {
if (pageIndex < 0 || pageIndex >= getChildCount()) {
return null;
}
return (DesktopPage) getChildAt(pageIndex);
}
public boolean canGoToNextPage() {
return currentPage < getChildCount() - 1;
}
public boolean canGoToPreviousPage() {
return currentPage > 0;
}
/**
* 普通手势翻页,带滚动动画。
*/
public void snapToPage(int targetPage) {
if (getChildCount() == 0) {
return;
}
targetPage = Math.max(
0,
Math.min(targetPage, getChildCount() - 1)
);
currentPage = targetPage;
int targetScrollX = currentPage * getWidth();
smoothScrollTo(targetScrollX);
}
/**
* 拖动图标时使用的翻页方法。
*
* 这里不带动画,原因是:
* 拖动过程中如果 Workspace 正在平滑滚动,
* 页面坐标会持续变化,导致图标落点计算不稳定。
*/
public void snapToPageImmediately(int targetPage) {
if (getChildCount() == 0) {
return;
}
targetPage = Math.max(
0,
Math.min(targetPage, getChildCount() - 1)
);
currentPage = targetPage;
cancelScrollAnimation();
int targetScrollX = currentPage * getWidth();
scrollTo(targetScrollX, 0);
}
/**
* 在最后一页继续向右拖动时,动态创建一个空白页面。
*/
public DesktopPage addNewPage(
Context context,
int countX,
int countY
) {
DesktopPage page = new DesktopPage(
context,
countX,
countY
);
addView(
page,
new LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
);
/*
* 新页面刚 addView 后,可能尚未进入下一帧 measure/layout。
*
* 但拖拽逻辑会立即调用 page.pointToCell(),
* 因此这里先完成一次初始布局,
* 防止 cellWidth / cellHeight 为 0。
*/
int pageIndex = getChildCount() - 1;
int pageWidth = getWidth();
int pageHeight = getHeight();
if (pageWidth > 0 && pageHeight > 0) {
page.measure(
View.MeasureSpec.makeMeasureSpec(
pageWidth,
View.MeasureSpec.EXACTLY
),
View.MeasureSpec.makeMeasureSpec(
pageHeight,
View.MeasureSpec.EXACTLY
)
);
page.layout(
pageIndex * pageWidth,
0,
(pageIndex + 1) * pageWidth,
pageHeight
);
}
requestLayout();
return page;
}
private void smoothScrollTo(int targetScrollX) {
cancelScrollAnimation();
int startScrollX = getScrollX();
scrollAnimator = ValueAnimator.ofInt(
startScrollX,
targetScrollX
);
scrollAnimator.setDuration(250);
scrollAnimator.addUpdateListener(animation -> {
int scrollX = (int) animation.getAnimatedValue();
scrollTo(scrollX, 0);
});
scrollAnimator.start();
}
private void cancelScrollAnimation() {
if (scrollAnimator != null && scrollAnimator.isRunning()) {
scrollAnimator.cancel();
}
scrollAnimator = null;
}
/**
* 普通状态下,Workspace 处理左右滑动翻页。
*
* 图标拖拽状态下,DesktopRootLayout 会优先拦截事件,
* 因此不会触发这里的页面滑动。
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
lastX = downX;
break;
case MotionEvent.ACTION_MOVE:
float dx = event.getX() - downX;
if (Math.abs(dx) > touchSlop) {
return true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
break;
default:
break;
}
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
lastX = downX;
cancelScrollAnimation();
return true;
case MotionEvent.ACTION_MOVE:
float currentX = event.getX();
/*
* 手指向左移动时 deltaX > 0,
* Workspace 向右滚动,显示下一页。
*/
float deltaX = lastX - currentX;
int targetScrollX = (int) (getScrollX() + deltaX);
int minScrollX = 0;
int maxScrollX = Math.max(
0,
(getChildCount() - 1) * getWidth()
);
targetScrollX = Math.max(
minScrollX,
Math.min(targetScrollX, maxScrollX)
);
scrollTo(targetScrollX, 0);
lastX = currentX;
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
int pageWidth = getWidth();
if (pageWidth > 0) {
int targetPage = Math.round(
(float) getScrollX() / pageWidth
);
snapToPage(targetPage);
}
return true;
default:
return true;
}
}
@Override
protected void onMeasure(
int widthMeasureSpec,
int heightMeasureSpec
) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(width, height);
/*
* 每一页大小都与 Workspace 可见区域一致。
*/
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
child.measure(
MeasureSpec.makeMeasureSpec(
width,
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
height,
MeasureSpec.EXACTLY
)
);
}
}
@Override
protected void onLayout(
boolean changed,
int left,
int top,
int right,
int bottom
) {
int pageWidth = getWidth();
/*
* 页面在横向上依次排列:
*
* Page 0: [0W, 1W]
* Page 1: [1W, 2W]
* Page 2: [2W, 3W]
*/
for (int i = 0; i < getChildCount(); i++) {
View page = getChildAt(i);
int pageLeft = i * pageWidth;
page.layout(
pageLeft,
0,
pageLeft + pageWidth,
getHeight()
);
}
}
}
workspace 是page 的容器
package com.example.mycarlauncher.widget;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.view.View;
import android.view.ViewGroup;
import com.example.mycarlauncher.data.DesktopItem;
import java.util.ArrayList;
import java.util.List;
/**
* 单个桌面页面。
*
* 每个 DesktopPage 是固定行列的图标网格。
*
* 负责:
* 1. 根据 cellX / cellY 布局图标;
* 2. 将坐标转换为 Cell;
* 3. 判断 Cell 是否被占用;
* 4. 绘制拖动目标预览;
* 5. 图标自动让位和重排。
*/
public class DesktopPage extends ViewGroup {
private final int countX;
private final int countY;
private int cellWidth;
private int cellHeight;
/*
* 拖动目标格子预览状态。
*/
private boolean hasPreview = false;
private boolean previewValid = false;
private int previewCellX = -1;
private int previewCellY = -1;
private final Paint previewPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public DesktopPage(
Context context,
int countX,
int countY
) {
super(context);
this.countX = countX;
this.countY = countY;
setWillNotDraw(false);
setClipChildren(false);
setClipToPadding(false);
}
public int getCountX() {
return countX;
}
public int getCountY() {
return countY;
}
public int getCellWidth() {
return cellWidth;
}
public int getCellHeight() {
return cellHeight;
}
/**
* 将页面内部坐标转换为格子坐标。
*
* @return int[2]:
* [0] = cellX
* [1] = cellY
*/
public int[] pointToCell(float x, float y) {
/*
* 新页面刚创建、还未完成 measure 时,
* 避免发生除以 0 的异常。
*/
if (cellWidth <= 0 || cellHeight <= 0) {
return new int[]{0, 0};
}
int cellX = (int) (x / cellWidth);
int cellY = (int) (y / cellHeight);
cellX = Math.max(0, Math.min(cellX, countX - 1));
cellY = Math.max(0, Math.min(cellY, countY - 1));
return new int[]{cellX, cellY};
}
/**
* 判断一个 Cell 是否已被占用。
*
* @param ignoreView 正在拖动的图标。
* 该图标在拖动时应被视作不占用任何格子。
*/
public boolean isCellOccupied(
int cellX,
int cellY,
View ignoreView
) {
if (!isCellValid(cellX, cellY)) {
return true;
}
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child == ignoreView) {
continue;
}
DesktopItem item = (DesktopItem) child.getTag();
if (item == null) {
continue;
}
if (item.cellX == cellX && item.cellY == cellY) {
return true;
}
}
return false;
}
/**
* 判断目标位置是否可以通过自动让位腾出来。
*/
public boolean canReorder(
int targetCellX,
int targetCellY,
View dragView
) {
if (!isCellValid(targetCellX, targetCellY)) {
return false;
}
int targetRank = cellToRank(targetCellX, targetCellY);
return findNearestVacantRank(targetRank, dragView) != -1;
}
/**
* 为当前拖动执行临时重排。
*
* 该方法只修改 DesktopItem 的 cellX/cellY,
* 不会写入数据库。
*
* 数据库更新由 DesktopRootLayout 在松手后统一完成。
*
* @return 因重排而移动过的图标列表;
* 如果找不到空位,则返回 null。
*/
public List<DesktopItem> reorderForDrop(
int targetCellX,
int targetCellY,
View dragView
) {
if (!isCellValid(targetCellX, targetCellY)) {
return null;
}
int targetRank = cellToRank(targetCellX, targetCellY);
int vacantRank = findNearestVacantRank(
targetRank,
dragView
);
if (vacantRank == -1) {
return null;
}
List<DesktopItem> changedItems = new ArrayList<>();
/*
* 空位在目标格子之后:
*
* 初始:
* [A][B][C][ ][D]
*
* 拖动图标放到 B:
*
* [A][ ][B][C][D]
*
* B 到空位前的图标依次向后移动一格。
*/
if (vacantRank > targetRank) {
for (int rank = vacantRank - 1;
rank >= targetRank;
rank--) {
DesktopItem item = findItemAtRank(
rank,
dragView
);
if (item == null) {
continue;
}
int[] newCell = rankToCell(rank + 1);
item.cellX = newCell[0];
item.cellY = newCell[1];
changedItems.add(item);
}
}
/*
* 空位在目标格子之前:
*
* 初始:
* [ ][A][B][C][D]
*
* 拖动图标放到 C:
*
* [A][B][ ][C][D]
*
* 空位与目标格子之间的图标依次向前移动一格。
*/
if (vacantRank < targetRank) {
for (int rank = vacantRank + 1;
rank <= targetRank;
rank++) {
DesktopItem item = findItemAtRank(
rank,
dragView
);
if (item == null) {
continue;
}
int[] newCell = rankToCell(rank - 1);
item.cellX = newCell[0];
item.cellY = newCell[1];
changedItems.add(item);
}
}
requestLayout();
return changedItems;
}
/**
* 从目标位置向前、向后查找最近空格。
*
* 如果距离相同,优先使用目标位置后方的空格。
*/
private int findNearestVacantRank(
int targetRank,
View ignoreView
) {
int totalCells = countX * countY;
for (int distance = 1; distance < totalCells; distance++) {
int nextRank = targetRank + distance;
int previousRank = targetRank - distance;
if (nextRank < totalCells
&& findItemAtRank(nextRank, ignoreView) == null) {
return nextRank;
}
if (previousRank >= 0
&& findItemAtRank(previousRank, ignoreView) == null) {
return previousRank;
}
}
return -1;
}
/**
* 查询指定 rank 上的图标数据。
*
* rank 规则:
*
* 第 0 行:
* 0, 1, 2, 3...
*
* 第 1 行:
* countX, countX + 1...
*/
private DesktopItem findItemAtRank(
int rank,
View ignoreView
) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child == ignoreView) {
continue;
}
DesktopItem item = (DesktopItem) child.getTag();
if (item == null) {
continue;
}
int itemRank = cellToRank(item.cellX, item.cellY);
if (itemRank == rank) {
return item;
}
}
return null;
}
/**
* Cell 坐标转为线性 rank。
*/
private int cellToRank(int cellX, int cellY) {
return cellY * countX + cellX;
}
/**
* 线性 rank 转为 Cell 坐标。
*
* @return int[2]:
* [0] = cellX
* [1] = cellY
*/
private int[] rankToCell(int rank) {
int cellX = rank % countX;
int cellY = rank / countX;
return new int[]{cellX, cellY};
}
private boolean isCellValid(int cellX, int cellY) {
return cellX >= 0
&& cellX < countX
&& cellY >= 0
&& cellY < countY;
}
/**
* 显示拖动落点预览。
*
* valid == true:绿色,表示可以放置;
* valid == false:红色,表示不可放置。
*/
public void setDropPreview(
int cellX,
int cellY,
boolean valid
) {
if (!isCellValid(cellX, cellY)) {
clearDropPreview();
return;
}
if (hasPreview
&& previewCellX == cellX
&& previewCellY == cellY
&& previewValid == valid) {
return;
}
hasPreview = true;
previewCellX = cellX;
previewCellY = cellY;
previewValid = valid;
invalidate();
}
public void clearDropPreview() {
if (!hasPreview) {
return;
}
hasPreview = false;
previewCellX = -1;
previewCellY = -1;
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (!hasPreview
|| previewCellX < 0
|| previewCellY < 0
|| cellWidth <= 0
|| cellHeight <= 0) {
return;
}
int left = previewCellX * cellWidth;
int top = previewCellY * cellHeight;
int right = left + cellWidth;
int bottom = top + cellHeight;
RectF previewRect = new RectF(
left + dp(6),
top + dp(6),
right - dp(6),
bottom - dp(6)
);
if (previewValid) {
previewPaint.setColor(0x5532CD32);
previewPaint.setStyle(Paint.Style.FILL);
canvas.drawRoundRect(
previewRect,
dp(12),
dp(12),
previewPaint
);
previewPaint.setColor(Color.GREEN);
previewPaint.setStyle(Paint.Style.STROKE);
previewPaint.setStrokeWidth(dp(2));
canvas.drawRoundRect(
previewRect,
dp(12),
dp(12),
previewPaint
);
} else {
previewPaint.setColor(0x55FF0000);
previewPaint.setStyle(Paint.Style.FILL);
canvas.drawRoundRect(
previewRect,
dp(12),
dp(12),
previewPaint
);
previewPaint.setColor(Color.RED);
previewPaint.setStyle(Paint.Style.STROKE);
previewPaint.setStrokeWidth(dp(2));
canvas.drawRoundRect(
previewRect,
dp(12),
dp(12),
previewPaint
);
}
previewPaint.setStyle(Paint.Style.FILL);
}
@Override
protected void onMeasure(
int widthMeasureSpec,
int heightMeasureSpec
) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(width, height);
cellWidth = width / countX;
cellHeight = height / countY;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
child.measure(
MeasureSpec.makeMeasureSpec(
cellWidth,
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(
cellHeight,
MeasureSpec.EXACTLY
)
);
}
}
@Override
protected void onLayout(
boolean changed,
int left,
int top,
int right,
int bottom
) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
DesktopItem item = (DesktopItem) child.getTag();
if (item == null) {
continue;
}
int childLeft = item.cellX * cellWidth;
int childTop = item.cellY * cellHeight;
child.layout(
childLeft,
childTop,
childLeft + cellWidth,
childTop + cellHeight
);
}
}
private int dp(int value) {
return (int) (
value * getResources().getDisplayMetrics().density + 0.5f
);
}
}
在page 中摆放 应用icon
package com.example.mycarlauncher.widget;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.GradientDrawable;
import android.text.TextUtils;
import android.view.Gravity;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
public class IconView extends LinearLayout {
private final ImageView iconView;
private final TextView titleView;
public IconView(Context context) {
super(context);
setOrientation(VERTICAL);
setGravity(Gravity.CENTER);
setPadding(dp(4), dp(8), dp(4), dp(4));
iconView = new ImageView(context);
iconView.setScaleType(ImageView.ScaleType.FIT_CENTER);
titleView = new TextView(context);
titleView.setTextColor(Color.WHITE);
titleView.setTextSize(14);
titleView.setGravity(Gravity.CENTER);
titleView.setSingleLine(true);
titleView.setEllipsize(TextUtils.TruncateAt.END);
titleView.setShadowLayer(3, 1, 1, Color.BLACK);
addView(iconView, new LayoutParams(dp(64), dp(64)));
LayoutParams titleParams = new LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT
);
titleParams.topMargin = dp(4);
addView(titleView, titleParams);
setBackground(createPressedBackground());
setClickable(true);
setFocusable(true);
}
public void bind(String title, android.graphics.drawable.Drawable icon) {
titleView.setText(title);
iconView.setImageDrawable(icon);
}
private GradientDrawable createPressedBackground() {
GradientDrawable drawable = new GradientDrawable();
drawable.setColor(0x00000000);
drawable.setCornerRadius(dp(12));
return drawable;
}
private int dp(int value) {
return (int) (
value * getResources().getDisplayMetrics().density + 0.5f
);
}
}