天天看點

[UWP]如何實作UWP平台最佳圖檔裁剪控件

原文: [UWP]如何實作UWP平台最佳圖檔裁剪控件 前幾天我寫了一個UWP圖檔裁剪控件ImageCropper( 開源位址 ),自認為算是現階段UWP社群裡最好用的圖檔裁剪控件了,今天就來分享下我編碼的過程。

為什麼又要造輪子

因為開發需要,我們需要使用一個圖檔裁剪控件來編輯使用者上傳的圖檔。本着盡量不重複造輪子的原則,我找了下現在UWP生态圈裡可用的圖檔裁剪控件,然後發現一個悲慘的事實:UWP生态圈甚至沒有一個體驗優秀的圖檔裁剪控件!

舉例來說,就連現在商店裡做的比較好的網易雲音樂、IT之家以及愛奇藝等應用,他們使用的圖檔裁剪控件體驗也糟糕的一塌糊塗(有認識他們開發人員的大佬,歡迎把我的這篇文章推薦給他們,不怕打臉)。

下圖是愛奇藝與IT之家的頭像裁剪控件:

[UWP]如何實作UWP平台最佳圖檔裁剪控件

那麼好吧,我們隻好又來造輪子了!

借鑒優秀的前輩

現階段在Windows平台上,最讓我稱佩的裁剪圖檔的應用就是Windows照片了。

[UWP]如何實作UWP平台最佳圖檔裁剪控件

它有以下兩個優點:

  • 裁剪區域永遠顯示在視覺中心,突出重點;
  • 操作體驗順暢,觸屏操作也能有很好體驗。

這次我們就來“抄襲”一下這個系統應用。

如何實作

有了實作目标,接下來就是思考如何編碼實作了。

需要哪些屬性來控制裁剪區域

分析一下這個控件的組成部分,其實就是由三部分組成的:最下層裁剪源圖像,上層控制裁剪區域的四個按鈕,以及遮蓋在圖像上的黑色半透明遮罩層。

是以我定義了下面幾個依賴屬性來控制界面:

  • SourceImage:類型為

    WriteableBitmap

    ,控制裁剪圖像源;
  • X1,Y1,X2,Y2:這四個

    double

    值,控制剪裁區域左上角與右下角兩個點坐标;
  • AspectRatio:類型為

    double

    值,控制裁剪圖像縱橫比;
  • MaskArea:類型為

    GeometryGroup

    ,控制黑色半透明遮罩層;
  • ImageTransform:類型為

    CompositeTransform

    ,控制裁剪過程中的源圖像變換。

這樣的話,更改裁剪區域隻需要修改X1,Y1,X2,Y2這四個值就可以了。

[UWP]如何實作UWP平台最佳圖檔裁剪控件

另外,如果我們通過拖動圖檔來移動選擇區域,同樣是修改X1,Y1,X2,Y2的值(而不是對圖檔進行變換,動圖中可能看不出來,源代碼中可以看到)。

[UWP]如何實作UWP平台最佳圖檔裁剪控件

控制裁剪圖像源Transform

在Windows照片應用裁剪圖檔控件中,其體驗良好的一個主要原因就是剪裁區域永遠處于視覺中心,這是通過控制裁剪圖像源在界面上的Transform來完成的。

[UWP]如何實作UWP平台最佳圖檔裁剪控件

我們可以看到,裁剪圖像源的變換規則如下:

  • 裁剪區域永遠位于界面中心(使用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;
    }           

核心邏輯代碼

下圖是這個圖檔剪裁控件的核心邏輯:

[UWP]如何實作UWP平台最佳圖檔裁剪控件

其中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的東西,請大家敬請期待!

這篇文章到此結束,謝謝大家閱讀!

繼續閱讀