前幾天我寫了一個UWP圖片裁剪控件ImageCropper(開源地址),自認爲算是現階段UWP社區裏最好用的圖片裁剪控件了,今天就來分享下我編碼的過程。git
由於開發須要,咱們須要使用一個圖片裁剪控件來編輯用戶上傳的圖片。本着儘可能不重複造輪子的原則,我找了下如今UWP生態圈裏可用的圖片裁剪控件,而後發現一個悲慘的事實:UWP生態圈甚至沒有一個體驗優秀的圖片裁剪控件!github
舉例來講,就連如今商店裏作的比較好的網易雲音樂、IT之家以及愛奇藝等應用,他們使用的圖片裁剪控件體驗也糟糕的一塌糊塗(有認識他們開發人員的大佬,歡迎把個人這篇文章推薦給他們,不怕打臉)。canvas
下圖是愛奇藝與IT之家的頭像裁剪控件:c#
那麼好吧,咱們只好又來造輪子了!佈局
現階段在Windows平臺上,最讓我稱佩的裁剪圖片的應用就是Windows照片了。動畫
它有如下兩個優勢:this
此次咱們就來「抄襲」一下這個系統應用。編碼
有了實現目標,接下來就是思考如何編碼實現了。3d
分析一下這個控件的組成部分,其實就是由三部分組成的:最下層裁剪源圖像,上層控制裁剪區域的四個按鈕,以及遮蓋在圖像上的黑色半透明遮罩層。code
因此我定義了下面幾個依賴屬性來控制界面:
WriteableBitmap
,控制裁剪圖像源;double
值,控制剪裁區域左上角與右下角兩個點座標;double
值,控制裁剪圖像縱橫比;另外還定義了兩個主要的私有屬性用來更新界面佈局:
GeometryGroup
,控制黑色半透明遮罩層;CompositeTransform
,控制裁剪過程當中的源圖像變換。這樣的話,更改裁剪區域只須要修改X1,Y1,X2,Y2這四個值就能夠了。
另外,若是咱們經過拖動圖片來移動選擇區域,一樣是修改X1,Y1,X2,Y2的值(而不是對圖片進行變換,動圖中可能看不出來,源代碼中能夠看到)。
在Windows照片應用裁剪圖片控件中,其體驗良好的一個主要緣由就是剪裁區域永遠處於視覺中心,這是經過控制裁剪圖像源在界面上的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() { _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() { _maskAreaGeometryGroup.Children.Clear(); _maskAreaGeometryGroup.Children.Add(new RectangleGeometry { Rect = new Rect(-_layoutGrid.Padding.Left, -_layoutGrid.Padding.Top, _layoutGrid.ActualWidth, _layoutGrid.ActualHeight) }); _maskAreaGeometryGroup.Children.Add(new RectangleGeometry {Rect = new Rect(new Point(X1, Y1), new Point(X2, Y2))}); _layoutGrid.Clip = new RectangleGeometry { Rect = new Rect(0, 0, _layoutGrid.ActualWidth, _layoutGrid.ActualHeight) }; }
到這裏,這個控件的全部東西就講的差很少了,你們有沒有以爲還缺了點什麼?
對的,它還缺乏了裁剪圖像源Transform變化時的過渡動畫,對於優秀的用戶體驗來講,這是不可或缺的!
以後我會抽時間補完這部分,而且跟你們講一點Composition Api的東西,請你們敬請期待!
這篇文章到此結束,謝謝你們閱讀!