聊聊如何实现Android 放大镜效果

一、前言

很久没有更新Android 原生技术内容了,前些年一直在做跨端方向开发,最近换工作用重新回到原生技术,又回到了熟悉但有些生疏的环境,真是感慨万分。

近期也是因为准备做地图交互相关的需求,功能非常复杂,尤其是交互部分,不过再复杂的交互,只要一点点将它拆解,分而治之,问题还是可以解决,就比如接下来要做的放大镜功能。

二、功能设计

该功能的场景是在操作地图时,对于边缘的精细化操作(像素级别的)需要在放大镜里显示正在操作的地图区域。比如

如上图,我在操作地图里的内容时,可以在左上角看到我手指操作的内容。

这里需要支持如下几点:

  • 支持设置放大镜的放大倍数
  • 支持实时更新放大镜里的内容,手指操作地图时,放大镜要里的内容要一直显示(刷新),手指松开时,放大镜里的内容要清空。
  • 放大镜是个圆形。

三、功能实现

分下下来,放大镜的功能实现,拆解下来可以分成两部分来实现。

  • 绘制圆形容器。
  • 绘制手指操作的区域内容。
    接下来我们逐个实现

3.1 绘制圆形容器

关于绘制圆形容器,这里会涉及到Path 路径知识,因为正常的图形都是方形,因此需要图形裁切才行,这里会涉及到canvas.clipPath()API。

具体代码如下:

java 复制代码
	private Paint paint;
    // 用于裁剪成圆形
    private Path clipPath;
	private void init() {
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        clipPath = new Path();
    }
    @Override
    protected void onDraw(@NonNull Canvas canvas) {
        super.onDraw(canvas);
        // 获取 MagnifierView 的宽高
        int viewWidth = getWidth();
        int viewHeight = getHeight();
        // 计算圆形半径
        float radius = Math.min(viewWidth, viewHeight) / 2f;
        clipPath.reset();
        // 绘制圆形路径
        clipPath.addCircle(viewHeight / 2f, (float) viewHeight /2,radius,Path.Direction.CW);
        // 裁切圆形画布
        canvas.clipPath(clipPath);
    }

3.2 绘制手指操作区域

放大镜本质就是放大图片,而图片在Android 里面就是Bitmap。这里有个问题。

3.2.1 如何获取当前手指操作View 的Bitmap呢?

答案是用getDrawingCache()。

具体实现如下:

java 复制代码
setDrawingCacheEnabled(true);
Bitmap bitmap = Bitmap.createBitmap(getDrawingCache());
setDrawingCacheEnabled(false);

当然,这里里的Bitmap是一整个View的,我们仅想放大手指操作(比如点击、滑动)区域的Bitmap,这就需要手指所在的坐标区域。

我们可以自定义一个View,然后重写它的onTouchEvent方法,通过event.getX(), event.getY()获取手指实时操作的坐标,然后传给放大镜View(比如我们这次自定义的放大镜MagnifierView)里面,连同前面的Bitmap一起传过去。这里可以搞一个回调接口。

java 复制代码
	public interface MagnifierListener {
        // 传递放大镜内容
        void onMagnify(Bitmap bitmap, float x, float y);

        // 隐藏放大镜
        void onHideMagnifier();
    }

3.2.2 绘制操作区域

得到了要绘制的内容Bitmap,接下来就是绘制出来。

绘制Bitmap很简单,就是调用下面的API:

java 复制代码
canvas.drawBitmap(bitmap, srcRect, dstRect, paint);

复杂的是计算放大内容的源区域,也就是srcRect。

这里先解释下dstRect,它是定义目标矩形(也就是放大镜自身大小)实现如下:

java 复制代码
RectF dstRect = new RectF(0, 0, viewWidth, viewHeight);

接下来我们开始设置srcRect。写到这里我们好像漏了一个关键参数"放大倍数"。

java 复制代码
// 放大倍数
private static final float SCALE_FACTOR = 2.0f;

因为我们要显示的区域和放大倍数有直接关联。srcRect 是一个Rect 对象,它里面有四个参数(左上右下),相当于当前显示区域范围,计算公式如下:

java 复制代码
// 计算放大内容的源区域
float srcLeft = focusX - (viewWidth / SCALE_FACTOR) / 2;
float srcTop = focusY - (viewHeight / SCALE_FACTOR) / 2;
float srcRight = focusX + (viewWidth / SCALE_FACTOR) / 2;
float srcBottom = focusY + (viewHeight / SCALE_FACTOR) / 2;

先来解释下上面的公式意义,focusX对应的就是手指操作的x坐标,即event.getX(),focusY 同理则是event.getY()。

SCALE_FACTOR 是放大倍数。

因为我们想要显示的是"手指为中心,显示区域大小是当前放大镜的面积",因此需要(viewWidth / SCALE_FACTOR) / 2,这里用focusX是确定左边界的位置。后面参数计算和前面差不多,这里我不重复解释。不过写到这里还不算完成。为了防止越界,这里还需要做一次矫正防护:

java 复制代码
// 防止越界
srcLeft = Math.max(0, srcLeft);
srcTop = Math.max(0, srcTop);
srcRight = Math.min(bitmap.getWidth(), srcRight);
srcBottom = Math.min(bitmap.getHeight(), srcBottom);

最后的放大区域代码如下:

java 复制代码
		// 计算放大内容的源区域
        float srcLeft = focusX - (viewWidth / SCALE_FACTOR) / 2;
        float srcTop = focusY - (viewHeight / SCALE_FACTOR) / 2;
        float srcRight = focusX + (viewWidth / SCALE_FACTOR) / 2;
        float srcBottom = focusY + (viewHeight / SCALE_FACTOR) / 2;

        // 防止越界
        srcLeft = Math.max(0, srcLeft);
        srcTop = Math.max(0, srcTop);
        srcRight = Math.min(bitmap.getWidth(), srcRight);
        srcBottom = Math.min(bitmap.getHeight(), srcBottom);

        // 定义源矩形(放大的内容区域)
        Rect srcRect = new Rect(
                (int) srcLeft,
                (int) srcTop,
                (int) srcRight,
                (int) srcBottom
        );

3.3 小结

这里附上这个功能的完整代码:

java 复制代码
/**
 * author      : coffer
 * date        : 2025/1/11
 * description : 放大镜视图
 */
public class MagnifierView extends View {
    private float focusX = 0f; // 放大内容的中心点X
    private float focusY = 0f; // 放大内容的中心点Y
    // 要放大的内容
    private Bitmap bitmap;
    private Paint paint;
    // 用于裁剪成圆形
    private Path clipPath;
    // 放大倍数
    private static final float SCALE_FACTOR = 2.0f;
    // 是否可以显示
    private boolean isVisible = false;
    public MagnifierView(Context context) {
        super(context);
        init();
    }

    public MagnifierView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public MagnifierView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        clipPath = new Path();
    }

    /**
     * 设置放大镜的内容和位置
     */
    public void setFocus(Bitmap bitmap, float x, float y) {
        this.bitmap = bitmap;
        this.focusX = x;
        this.focusY = y;
        this.isVisible = true;
        invalidate(); // 触发重绘
    }

    /**
     * 隐藏放大镜
     */
    public void hide() {
        this.isVisible = false;
        invalidate();
    }

    @Override
    protected void onDraw(@NonNull Canvas canvas) {
        super.onDraw(canvas);
        if (!isVisible || bitmap == null) {
            return;
        }
        // 获取 MagnifierView 的宽高
        int viewWidth = getWidth();
        int viewHeight = getHeight();
        // 计算圆形半径
        float radius = Math.min(viewWidth, viewHeight) / 2f;

        clipPath.reset();
        clipPath.addCircle(viewHeight / 2f, (float) viewHeight /2,radius,Path.Direction.CCW);
        canvas.clipPath(clipPath);
        // 计算放大内容的源区域
        float srcLeft = focusX - (viewWidth / SCALE_FACTOR) / 2;
        float srcTop = focusY - (viewHeight / SCALE_FACTOR) / 2;
        float srcRight = focusX + (viewWidth / SCALE_FACTOR) / 2;
        float srcBottom = focusY + (viewHeight / SCALE_FACTOR) / 2;

        // 防止越界
        srcLeft = Math.max(0, srcLeft);
        srcTop = Math.max(0, srcTop);
        srcRight = Math.min(bitmap.getWidth(), srcRight);
        srcBottom = Math.min(bitmap.getHeight(), srcBottom);

        // 定义源矩形(放大的内容区域)
        Rect srcRect = new Rect(
                (int) srcLeft,
                (int) srcTop,
                (int) srcRight,
                (int) srcBottom
        );

        // 定义目标矩形(放大镜自身大小)
        RectF dstRect = new RectF(0, 0, viewWidth, viewHeight);

        // 绘制放大内容
        canvas.drawBitmap(bitmap, srcRect, dstRect, paint);
    }
}
xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <stroke android:color="#abf4db"
        android:width="1dp"/>

</shape>

上面的这个drawable 可有可无,这里这是方便测试用。

四、总结

一开始接到这个需求的时候我是真的有些懵,因为以前从来没有写过。不过后来在仔细拆分问题后,觉的还是可以实现的。这里我要着重感谢ChatGPT,它给了我很大的帮助,就比如这个功能的实现,它就给了我思路,就像老师一样,能从它身上学到很多技能。

但是,请注意!不能完全依赖它,即使AI 帮你做了,你也必须要深入搞懂背后的原理,要把知识吸收到自己大脑思维中,否则未来你将会被AI取代!!!

相关推荐
kk爱闹1 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空3 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭4 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日4 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安5 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑5 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟9 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡10 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0010 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
zhangphil12 小时前
Android理解onTrimMemory中ComponentCallbacks2的内存警戒水位线值
android