Android中实现类似HTML map标签的图片热区功能

一、 项目需求、实现目标

在开发中,我们有时候需要再点击图片不同区域的时候进行不同的操作,比如在展示一个地图图片,点击不同的地区,当前地区高亮或者播放该地区的介绍视频等;又或者在医疗健康的App中,需要点击人体图片的某个部位展示不同的医学建议等等。
我目前的项目需求是第二种情况。实现类似下图(图片来源网上)的效果:

Android中实现类似HTML map标签的图片热区功能_第1张图片

我们可以要求UI提供多张大小完全一致的图片,对应每个需要高亮的部位,点击对应区域的时候展示对应的图片然后执行对应的操作即可。
所以咱们最终目标是实现点击图片不同的地方触发不同的操作。这里有两种方案:

  1. 采用控件坐标系,点击控件的不同区域触发不同操作。这种方案需要确保控件和图片的坐标是一致的,比如图片是400x400,那么控件大小也设置成400x400,否则可能出现异常情况。这种方案实现起来简单,但是局限性大,容易出错
  2. 采用图片的坐标系,我们设置的坐标完全对应图片原始的坐标,无论怎么缩放,只要点击对应的区域就正常触发,这种方案实现稍微复杂一些,因为触摸回调传递给我们的是控件坐标,我们最重要转换成图片原始坐标来处理
    另外,为了提高易用性和灵活性,我们应当既可以在xml中定义点击区域,又可以动态地添加点击区域。

二、 实现思路

首先因为是展示图片,我们自定义的控件自然是继承ImageView或者AppCompatImageView。其次这个和触摸、点击相关,还需要获取对应的触摸事件的坐标,我们很容易想到在onTouchEvent中去处理,但是,如果我们重写onTouchEvent来处理点击事件,那需要做很多的判断,搞不好还容易出现滑动冲突之类的问题。所以这里我打算在performClick中处理,但是perforClick中没有传递对应的坐标,不过好在performClick是在onTouchEvent方法中调用的,所以在onTouchEvent方法中把对应的坐标记录下来即可。


public class ImageMapView extends AppCompatImageView {
    
    private float currentX;
    private float currentY;

    ...

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        this.currentX = event.getX();
        this.currentY = event.getY();
        return super.onTouchEvent(event);
    }

    @Override
    public boolean performClick() {
        OnAreaClickListener listener = this.onAreaClickListener;

        if(listener != null) {
            Point point = getImagePoint(currentX, currentY);
            Log.v(TAG, "Map coor: " + point);
            synchronized (areas) {
                for (Area area : areas) {
                    if(area.isInArea(point.x, point.y)) {
                        Log.d(TAG, "Area " + area.getName() + "[" + area.getId() + "] was clicked");
                        listener.onAreaClicked(this, area);
                        return true;
                    }
                }
            }
        }
        
        return super.performClick();
    }

    ...
}

那么还有一个问题,就是需要把当前触摸事件的坐标转化成图片原始的坐标


private Point getImagePoint(float x, float y) {
    Matrix matrix = getImageMatrix();
    Matrix copy = new Matrix();
    matrix.invert(copy);
    RectF rectF = new RectF();
    Drawable drawable = getDrawable();
    if (drawable != null) {
        rectF.set(0, 0, x, y);
        copy.mapRect(rectF);
        float scaleX = mapWidth * 1.0f / drawable.getIntrinsicWidth();
        float scaleY = mapHeight * 1.0f / drawable.getIntrinsicHeight();
        rectF.right = rectF.right * scaleX;
        rectF.bottom = rectF.bottom * scaleY;
    }

    return new Point(Math.round(rectF.right), Math.round(rectF.bottom));
}

剩下的就是区域的定义、判断落点是否在区域内了,这些都不是核心问题了。

这里展示一下XML定义点击区域:


<map xmlns:android="http://schemas.android.com/apk/res/android"
    width="500"
    height="500">
    
    <area name="Rect" shape="rect" coords="62,49,193,123" id="@+id/shape_rect"/>
    
    <area name="Circle" shape="circle" coords="211,262,50" id="@+id/shape_circle"/>
    
    <area name="Poly" shape="poly" coords="300,332,360,288,421,332,399,404,322,404" id="@+id/shape_poly"/>
map>

增加一个自定义属性:


<resources>
    <declare-styleable name="ImageMapView">
        <attr name="imageMap" format="reference"/>
    declare-styleable>
resources>

在代码里面设置自定义属性

<com.xinyanruanjian.imagemapview.ImageMapView
    android:id="@+id/imv"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:src="@drawable/shapes"
    android:scaleType="fitCenter"
    app:imageMap="@xml/map"/>

在自定义控件中加载这些区域定义

public class ImageMapView extends AppCompatImageView {

    public ImageMapView(@NonNull Context context) {
        this(context, null);
    }

    public ImageMapView(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ImageMapView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        if(attrs != null) {
            TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.ImageMapView);
            int resourceId = ta.getResourceId(R.styleable.ImageMapView_imageMap, 0);
            if(resourceId != 0) {
                loadMapInfo(resourceId);
            }

            ta.recycle();
        }

        if(onClickListener == null) {
            setOnClickListener(v -> {});
        }
    }

    private void loadMapInfo(int xmlId) {
        try {
            XmlResourceParser xpp = getResources().getXml(xmlId);

            int eventType = xpp.getEventType();
            while (eventType != XmlPullParser.END_DOCUMENT) {
                if (eventType == XmlPullParser.START_DOCUMENT) {
                    // Start document
                    //  This is a useful branch for a debug log if
                    //  parsing is not working
                } else if (eventType == XmlPullParser.START_TAG) {
                    String tag = xpp.getName();
                    if("map".equalsIgnoreCase(tag)) {
                        mapHeight = xpp.getAttributeIntValue(null, "height", 0);
                        mapWidth = xpp.getAttributeIntValue(null, "width", 0);
                    } else if ("area".equalsIgnoreCase(tag)) {
                        Area a = null;
                        String shape = xpp.getAttributeValue(null, "shape");
                        String coords = xpp.getAttributeValue(null, "coords");
                        int id = xpp.getIdAttributeResourceValue(0);

                        // as a name for this area, try to find any of these
                        // attributes
                        //  name attribute is custom to this impl (not standard in html area tag)
                        String name = xpp.getAttributeValue(null, "name");
                        if (name == null) {
                            name = xpp.getAttributeValue(null, "title");
                        }
                        if (name == null) {
                            name = xpp.getAttributeValue(null, "alt");
                        }

                        if ((shape != null) && (coords != null)) {
                            a = addShape(shape, name, coords, id);
                            if (a != null) {
                                // add all of the area tag attributes
                                // so that they are available to the
                                // implementation if needed (see getAreaAttribute)
                                for (int i = 0; i < xpp.getAttributeCount(); i++) {
                                    String attrName = xpp.getAttributeName(i);
                                    String attrVal = xpp.getAttributeValue(null, attrName);
                                    a.addValue(attrName, attrVal);
                                }
                            }
                        }
                    }
                } else if (eventType == XmlPullParser.END_TAG) {

                }
                eventType = xpp.next();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        if(mapWidth == 0 || mapHeight == 0) {
            throw new IllegalStateException("width and height must not be 0, width: " + mapWidth + ", height: " + mapHeight);
        }

        Log.v(TAG, "Area size: " + areas.size());
    }

}

代码链接:Github

才疏学浅,如有不对,欢迎指正

你可能感兴趣的:(#,Android自定义View,android,图片热区,Android图片热区,Image,Map)