[UWP]如何實現UWP平臺最佳圖片裁剪控制元件
前幾天我寫了一個UWP圖片裁剪控制元件ImageCropper(開源地址),自認為算是現階段UWP社群裡最好用的圖片裁剪控制元件了,今天就來分享下我編碼的過程。
為什麼又要造輪子
因為開發需要,我們需要使用一個圖片裁剪控制元件來編輯使用者上傳的圖片。本著儘量不重複造輪子的原則,我找了下現在UWP生態圈裡可用的圖片裁剪控制元件,然後發現一個悲慘的事實:UWP生態圈甚至沒有一個體驗優秀的圖片裁剪控制元件!
舉例來說,就連現在商店裡做的比較好的網易雲音樂、IT之家以及愛奇藝等應用,他們使用的圖片裁剪控制元件體驗也糟糕的一塌糊塗(有認識他們開發人員的大佬,歡迎把我的這篇文章推薦給他們,不怕打臉)。
下圖是愛奇藝與IT之家的頭像裁剪控制元件:
那麼好吧,我們只好又來造輪子了!
借鑑優秀的前輩
現階段在Windows平臺上,最讓我稱佩的裁剪圖片的應用就是Windows照片了。
它有以下兩個優點:
- 裁剪區域永遠顯示在視覺中心,突出重點;
- 操作體驗順暢,觸屏操作也能有很好體驗。
這次我們就來“抄襲”一下這個系統應用。
如何實現
有了實現目標,接下來就是思考如何編碼實現了。
需要哪些屬性來控制裁剪區域
分析一下這個控制元件的組成部分,其實就是由三部分組成的:最下層裁剪源影象,上層控制裁剪區域的四個按鈕,以及遮蓋在影象上的黑色半透明遮罩層。
所以我定義了下面幾個依賴屬性來控制介面:
- SourceImage:型別為
WriteableBitmap
,控制裁剪影象源; - X1,Y1,X2,Y2:這四個
double
值,控制剪裁區域左上角與右下角兩個點座標; - AspectRatio:型別為
double
值,控制裁剪影象縱橫比; - MaskArea:型別為
GeometryGroup
,控制黑色半透明遮罩層; - ImageTransform:型別為
CompositeTransform
,控制裁剪過程中的源影象變換。
這樣的話,更改裁剪區域只需要修改X1,Y1,X2,Y2這四個值就可以了。
另外,如果我們通過拖動圖片來移動選擇區域,同樣是修改X1,Y1,X2,Y2的值(而不是對圖片進行變換,動圖中可能看不出來,原始碼中可以看到)。
控制裁剪影象源Transform
在Windows照片應用裁剪圖片控制元件中,其體驗良好的一個主要原因就是剪裁區域永遠處於視覺中心,這是通過控制裁剪影象源在介面上的Transform來完成的。
我們可以看到,裁剪影象源的變換規則如下:
- 裁剪區域永遠位於介面中心(使用Uniform規則);
- 當裁剪區域縮小時,在停止拖動裁剪框控制按鈕時,更新裁剪影象源的Transform;
- 當裁剪區域擴大時,實時更新裁剪影象源的Transform。
限制剪裁區域範圍
另外要注意的是,我們必須保證X1,Y1,X2,Y2取值範圍不超過圖片區域。
這裡有個關於Rect的坑要說明下。一開始我選用的判斷方法是:通過Rect.Contains方法傳入剪裁區域左上角與右下角兩個點座標,如果均為true,代表剪裁區域範圍合法。但是我發現,在Rect長寬為有小數部分的double值時,如果我把右下角座標設定為new Point(Rect.X + Rect.Width, Rect.Y + Rect.Height)
,這個方法會返回錯誤的false值,實在是坑爹!
因此,考慮到使用場景,我為Rect寫了另外一個擴充套件方法:
public static bool IsSafePoint(this Rect targetRect, Point point)
{
if (point.X - targetRect.X < 0.01)
return false;
if (point.X - (targetRect.X + targetRect.Width) > 0.01)
return false;
if (point.Y - targetRect.Y < 0.01)
return false;
if (point.Y - (targetRect.Y + targetRect.Height) > 0.01)
return false;
return true;
}
核心邏輯程式碼
下圖是這個圖片剪裁控制元件的核心邏輯:
其中InitImageLayout方法會在圖片源變化時被呼叫,它會初始化圖片佈局(通過呼叫UpdateImageLayout方法)。
private void InitImageLayout()
{
if (ImageTransform == null)
ImageTransform = new CompositeTransform();
_maxClipRect = new Rect(0, 0, SourceImage.PixelWidth, SourceImage.PixelHeight);
var maxSelectedRect = new Rect(1, 1, SourceImage.PixelWidth - 2, SourceImage.PixelHeight - 2);
_currentClipRect = KeepAspectRatio ? maxSelectedRect.GetUniformRect(AspectRatio) : maxSelectedRect;
UpdateImageLayout();
}
UpdateImageLayout方法用於初始化控制元件或者控制元件SizeChanged時,呼叫此方法更新控制元件佈局(通過呼叫UpdateImageLayoutWithViewport方法)。
private void UpdateImageLayout()
{
var canvasRect = new Rect(0, 0, CanvasWidth, CanvasHeight);
var uniformSelectedRect = canvasRect.GetUniformRect(_currentClipRect.Width / _currentClipRect.Height);
UpdateImageLayoutWithViewport(uniformSelectedRect, _currentClipRect);
}
UpdateImageLayoutWithViewport方法是更新控制元件佈局的核心邏輯,它接受兩個引數:viewport和viewportImgRect,其中viewport代表的是實際呈現在你視覺中心的區域,viewportImgRect表示viewport所對應的實際圖片區域(以實際畫素大小為單位),程式碼將通過這兩個引數更新裁剪影象源的Transform。
private void UpdateImageLayoutWithViewport(Rect viewport, Rect viewportImgRect)
{
var imageScale = viewport.Width / viewportImgRect.Width;
ImageTransform.ScaleX = ImageTransform.ScaleY = imageScale;
ImageTransform.TranslateX = viewport.X - viewportImgRect.X * imageScale;
ImageTransform.TranslateY = viewport.Y - viewportImgRect.Y * imageScale;
var selectedRect = ImageTransform.TransformBounds(_currentClipRect);
_limitedRect = ImageTransform.TransformBounds(_maxClipRect);
var startPoint = _limitedRect.GetSafePoint(new Point(selectedRect.X, selectedRect.Y));
var endPoint = _limitedRect.GetSafePoint(new Point(selectedRect.X + selectedRect.Width, selectedRect.Y + selectedRect.Height));
_changeByCode = true;
X1 = startPoint.X;
Y1 = startPoint.Y;
X2 = endPoint.X;
Y2 = endPoint.Y;
_changeByCode = false;
}
UpdateClipRectWithAspectRatio則在使用者對剪裁區域改變時被呼叫,其中dragPoint代表使用者操作的哪個按鈕,diffPos代表該按鈕的前後位置差值。
private void UpdateClipRectWithAspectRatio(DragPoint dragPoint, Point diffPos)
{
if (KeepAspectRatio)
{
if (Math.Abs(diffPos.X / diffPos.Y) > AspectRatio)
{
if (dragPoint == DragPoint.UpperLeft || dragPoint == DragPoint.LowerRight)
diffPos.Y = diffPos.X / AspectRatio;
else
diffPos.Y = -diffPos.X / AspectRatio;
}
else
{
if (dragPoint == DragPoint.UpperLeft || dragPoint == DragPoint.LowerRight)
diffPos.X = diffPos.Y * AspectRatio;
else
diffPos.X = -diffPos.Y * AspectRatio;
}
}
var startPoint = new Point(X1, Y1);
var endPoint = new Point(X2, Y2);
switch (dragPoint)
{
case DragPoint.UpperLeft:
startPoint.X += diffPos.X;
startPoint.Y += diffPos.Y;
break;
case DragPoint.UpperRight:
endPoint.X += diffPos.X;
startPoint.Y += diffPos.Y;
break;
case DragPoint.LowerLeft:
startPoint.X += diffPos.X;
endPoint.Y += diffPos.Y;
break;
case DragPoint.LowerRight:
endPoint.X += diffPos.X;
endPoint.Y += diffPos.Y;
break;
}
if (_limitedRect.IsSafePoint(startPoint) && _limitedRect.IsSafePoint(endPoint))
{
var canvasRect = new Rect(0, 0, CanvasWidth, CanvasHeight);
var newRect = new Rect(startPoint, endPoint);
canvasRect.Union(newRect);
if (canvasRect.X < 0 || canvasRect.Y < 0 || canvasRect.Width > CanvasWidth ||
canvasRect.Height > CanvasHeight)
{
var inverseImageTransform = ImageTransform.Inverse;
if (inverseImageTransform != null)
{
var movedRect = inverseImageTransform.TransformBounds(
new Rect(startPoint, endPoint));
movedRect.Intersect(_maxClipRect);
_currentClipRect = movedRect;
var oriCanvasRect = new Rect(0, 0, CanvasWidth, CanvasHeight);
var viewportRect = oriCanvasRect.GetUniformRect(canvasRect.Width / canvasRect.Height);
var viewportImgRect = inverseImageTransform.TransformBounds(canvasRect);
UpdateImageLayoutWithViewport(viewportRect, viewportImgRect);
}
}
else
{
X1 = startPoint.X;
Y1 = startPoint.Y;
X2 = endPoint.X;
Y2 = endPoint.Y;
}
}
}
UpdateMaskArea方法用來更新遮蓋在裁剪影象源上的黑色半透明遮罩層,其實就是影象上覆蓋了一個Path元素,這裡就不細講了,直接貼程式碼。
private void UpdateMaskArea()
{
_maskArea.Children.Clear();
_maskArea.Children.Add(new RectangleGeometry
{
Rect = new Rect(-_layoutGrid.Padding.Left, -_layoutGrid.Padding.Top, _layoutGrid.ActualWidth,
_layoutGrid.ActualHeight)
});
_maskArea.Children.Add(new RectangleGeometry {Rect = new Rect(new Point(X1, Y1), new Point(X2, Y2))});
MaskArea = _maskArea;
_layoutGrid.Clip = new RectangleGeometry
{
Rect = new Rect(0, 0, _layoutGrid.ActualWidth,
_layoutGrid.ActualHeight)
};
}
結尾
到這裡,這個控制元件的所有東西就講的差不多了,大家有沒有覺得還缺了點什麼?
對的,它還缺少了裁剪影象源Transform變化時的過渡動畫,對於優秀的使用者體驗來說,這是不可或缺的!
之後我會抽時間補完這部分,並且跟大家講一點Composition Api的東西,請大家敬請期待!
這篇文章到此結束,謝謝大家閱讀!