从0开始写launcher

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
        );
    }
}
相关推荐
choumou_M2 天前
SpringBoot_3在Gitee上创建远程仓库与协作开发教程
gitee
cakeism8253 天前
Gitee CodePecker:SCA+SAST双引擎驱动,从源头构筑DevSecOps研发安全防线
安全·gitee
老李要转行4 天前
【无标题】
gitee
7177774 天前
不止工具集成:基于 Gitee 软件工厂构建 DevSecOps 研发治理底座
java·服务器·gitee
7177774 天前
Gitee 推荐系统全链路解析:从项目筛选到 AI 智能协作
人工智能·gitee
Gavynlee4 天前
Git 操作问题排查与解决方案记录(Gitee 实战)
git·elasticsearch·gitee
7177775 天前
本土研发枢纽崛起:Gitee 企业版的价值跃迁
gitee
7177775 天前
Gitee Insight:国产化研发效能度量平台能力解析与选型参考
gitee
7177775 天前
厘清 Gitee Test 能力边界:测试管理、自动化与 DevSecOps 协同路径
运维·gitee·自动化