Implement a color-picker and magnifier in Android

Sep 20, 2015


UI calibration is one of the latest features comes into Hurdle. With this functionality, UEDs and developers are able to check whether the UI implementation is aligned with design spec. Color-picker and magnifier is one of the feature inside. Magnifier

Obtain bitmap from Activity

The content to show inside MagnifierView is a crop from background Activity bitmap. To obtain a bitmap of the Activity:
View decroView = activity.getWindow().getDecorView();
decroView.buildDrawingCache();
Bitmap activityBitmap = decroView.getDrawingCache();

Prepare drawing

Before implementing onDraw(), there are something to be prepared. First we have a content-bitmap and a mask-bitmap, both with the same size of the MagnifierView, say 130*130dp. The mask-bitmap is from a bitmap drawable which is a white-colored circle with transparent background.
// width and height is MagnifierView dimension
// the target bitmap which will be shown inside MagnifierView
Bitmap mTargetBitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888);
// mCanvasTarget will draw into mTargetBitmap
Canvas mCanvasTarget = new Canvas(mTargetBitmap);

// the activity-bitmap will be cropped and drawn into mContentBitmap first
Bitmap mContentBitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888);
Canvas mCanvasContent = new Canvas(mContentBitmap);

// the paint object to mask the content-bitmap with mask-bitmap
Paint mPaintMask = new Paint(Paint.ANTI_ALIAS_FLAG); 
mPaintMask.setXferMode(new PorterDuffXfermode(Mode.SRC_IN));

// magnify matrix
Matrix mMatrix = new Matrix();
mMatrix.setScale(1.2f, 1.2f);

Draw the magnifier

Whenever the magnifier is moved, calculate the part of activity-bitmap which should be shown inside, copy that area into content-bitmap, as the content-bitmap is much smaller than activity-bitmap, it is cropped. Then mask the content-bitmap against the mask-bitmap, which translate the shape of content-bitmap from rectanglar to circle.
@Override
public boolean onTouchEvent(MotionEvent event){
    float rawX = event.getRawX();
    float rawY = event.getRawY();
    if(event.getAction() == MotionEvent.ACTION_MOVE{
        // avoid obstruction by the finger, make the magnifier a little bit above the touch point
        int x = (int)(rawX - mContentBitmap.getWidth()/2);
        int y = (int)(rawY - mContentBitmap.getHeight());
        if(x < 0) x = 0;
        if(y < 0) y = 0;
        // update the content of the magnifier
        updateMagnifierContent(x, y);
        // update the positon of the magnifier with WindowManager
        updateMagnifierPosition(x, y);
    }
}
Somebody may point out that rawY should be substract by the height of system notification bar. But in fact, the Activity window is actually drawn full-screen, the notification bar is an overlay at the top. The Activity window just leaves the area obstructed as transparent.
/**
 * @param bmpLtX: the top-left location X of the bitmap to be drawn
 * @param bmpLtY: the top-left location Y of the bitmap to be drawn
 */
private void updateMagnifierContent(int bmpLtX, int bmpLtY){
    // draw the portion of activity-bitmap into mContentBitmap, as a intermediate buffer
    mContentBitmap.eraseColor(0);
    mCanvasContent.save();
    // note: negative values used
    mCanvasContent.translate(-bmpLtX, -bmpLtY);
    mCanvasContent.drawBitmap(mActivityBitmap, 0, 0, mPaint);
    mCanvasContent.restore();
    
    mTargetBitmap.eraseColor(0);
    // the drawble of mask-bitmap
    Drawable maskDrawable = getMaskDrawable();
    maskDrawable.draw(mCanvasTarget);
    mCanvasTarget.save();
    // mask mContentBitmap with mask-bitmap, by PorterDuffXfermode(mPaintMask),
    // to make it a circle,
    // with mMatrix, the content-bitmap is magnified by 20%
    mCanvasTarget.drawBitmap(mContentBitmap, mMatrix, mPaintMask);
    mCanvasTarget.restore();
    
    // draw other decorations ...
    
    // will trigger onDraw()
    invalidate();
}

@Override
public void onDraw(Canvas canvas){
    canvas.drawBitmap(mTargetBitmap, 0, 0, null);
}
Please note that the parameters passed to Canvas.translate() are (-bmpLtX, -bmpLtY), instead of (bmpLtX, bmpLtY). I was quite confusing about this at the begining. Translation moves the base point (0, 0) of the Canvas to (-bmpLtX, -bmpLtY), say (-150, -200). When the activity-bitmap is then drawn to the canvas, the base point of the bitmap is actually drawn at view coordinator (-150, -200), which is out of the screen and will not displayed. So the point of bitmap (150, 200) is now drawn at view coordinator (0, 0), that exactly what I want.

Update window location

The MagnifierView is show atop of any Activity. It's manipulated directly by WindowManager.
private void showMagnifier(){
    WindowManager.LayoutParams params = new WindowManager.LayoutParams();
    params.type = LayoutParams.SYSTEM_ALERT - 1;
    // FLAG_NOT_TOUCH_MODAL ensures that the magnifier doesn't caputre all the touch events outside the view
    params.flags = FLAG_ALT_FOCUSABLE_IM | FLAG_HARDWARE_ACCELERATED | FLAG_NOT_TOUCH_MODAL;
    params.format = PixelFormat.TRANSLUCENT;
    params.width = WRAP_CONTENT;
    params.height = WRAP_CONTENT;
    params.gravity = LEFT | TOP; // only for LEFT-TOP, params.x, y will take effect
    mWindowManager.addView(overlay, params);
}

private void updateMagnifierPosition(int x, int y){
    WindowManager.LayoutParams lp = (WindowManager.LayoutParams)this.getLayoutParams();
    lp.x = x;
    lp.y = y;
    mWindowManager.updateViewLayout(this, lp);
}