[Winform] 復原滑鼠事件造成的非預期慣性滾動問題

這是一個困擾了我一整天的問題。從昨天晚上發現問題開始, 一直到今天下班前才解決, 足足花了十幾個小時在跟這個應該不是問題的問題奮戰著。我並沒有上網尋求答案, 因為我一直以為是自己程式出錯而反覆修改, 但最後卻發現並不是自己的程式有問題, 而可能是 Windows 本身的問題, 亦或是自己對 Windows Application 中滑鼠事件生命週期理解有誤而造成的...

這是一個困擾了我一整天的問題。從昨天晚上發現問題開始, 一直到今天下班前才解決, 足足花了十幾個小時在跟這個應該不是問題的問題奮戰著。我並沒有上網尋求答案, 因為我一直以為是自己程式出錯而反覆修改, 但最後卻發現並不是自己的程式有問題, 而可能是 Windows 本身的問題, 亦或是自己對 Windows Application 中滑鼠事件生命週期理解有誤而造成的。

緣由

事情的由來是這樣的。我使用 GDI+ 產生了一個 GraphicsPath 物件, 然後在 Form.Paint 事件處理程序中利用一個 TextureBrush 並載入一個圖片之後, 將該圖形填入上述的 GraphicsPath 中。因為這個圖片有點大, 遠超過上述 GraphicsPath 描出來的形狀之外, 所以我就想說讓使用者可以在這個圖片上使用滑鼠拖曳, 以方便把顯示範圍拉到使用者感覺滿意的地方, 而不是每次都只能看到圖片的左上角。

拖曳的部份是怎麼做的呢? 我使用了三個事件:

  1. 在 MouseDown 事件中記錄滑鼠按下的位置 (originalPosition)
  2. 在 MouseMove 事件中計算滑鼠位置隨時相對於 originalPosition 的相對位置 (hbImageOffsetX, hbImageOffsetY); 在這個程序中也透過 this.Invalidate(); 指令讓視窗重繪
  3. 在 MouseUp 事件中把滑鼠的最後位置記錄起來 (lastOffsetX, lastOffsetY)

因此, 在整個拖曳事件中, 從按下滑鼠鍵 (MouseDown) 開始, 然後移動滑鼠 (MouseMove) 以調整填入圖案的位置, 最後再放開滑鼠鍵 (MouseUp) 為止, 使用者應該可以順利的對填入的圖片進行位置的微調。

問題

然而, 當我把程式寫好之後, 我發現了一個很小的問題: 我發現這個程式會讓填入的圖片自動具有慣性滾動的「效果」!

使用過智慧型手機的人都知道什麼叫做慣性滾動, 就是你的手指在觸控面板上進行拖曳時, 當你把手指放開後, 畫面還是會繼續滾動一段距離。聽起來很酷眩對吧! 不, 其實一點也不酷!

因為我根本沒有要它做到什麼慣性滾動! 我只希望讓使用者能精準的調整圖片位置而已; 如果我的程式沒這麼寫, 卻出現這種效果, 那麼這不叫做 bug 叫什麼?

就是因為我一直認定這是 bug, 所以我才會花那麼多時間反覆思索程式的邏輯。明明邏輯沒有問題, 我甚至都拿紙筆出來驗證過了, 但是結果怎麼就是跟程式的寫法不一樣呢?

最後, 我很痛苦的下了一個結論 (好吧, 這麼講是有點誇張啦): 我的程式沒問題, 是 Windows 有問題。

Windows 當然也會出問題; 寫程式的人多多少少會踫到 Windows 自己的問題。只是我一開始無法想像 Windows 會在這種地方出問題。

我想, 如果你不是也遇過這種事情的話, 你可能看到這裡還是不了解這問題到底是什麼。我舉個例子好了。

程式邏輯在上面已經列出來了, 你隨時可以翻上去參考。現在假設我在按下滑鼠鍵準備進行拖曳動作時, 於 MouseUp 事件中我記錄到初始位置是 (1, 1)。

接著, 我把滑鼠以直線往下拉, 那麼在各個 MouseMove 事件中, 滑鼠位址會陸續以 (1, 2), (1, 3), (1, 4)... 的順序出現。

最後, 比方說我拉到 (1, 5) 的位置時放開滑鼠鍵, 那麼我在 MouseUp 事件中會記錄滑鼠的最後位置就是 (1, 5)。

這樣子說明, 應該很容易理解吧? 那麼問題到底出在哪裡呢?

原因

問題就出在 MouseMove 事件裡面。經過無數次的追蹤, 我發現在 MouseMove 事件中, 即使我們在 (1, 5) 這個地方已經放開滑鼠鍵了, 可是 MouseMove 竟然會丟出像 (1, 7) 這樣的座標出來。換句話說, 我從來沒有把滑鼠拖到 (1, 7) 這個位置, 但是它就是會莫名其妙的丟出這種座標值出來。我同時發現, 如果你把滑鼠很快速的拉動, 那個座標有可能會暴增到像 (1, 12) 這麼離譜的數值。但如果你很緩慢的拉動, 這個偏移量也會跟著變小, 甚至沒有偏移量。

或許你會這麼問: 也許我在拖曳過程中, 當我把滑鼠鍵放開的時候, 滑鼠還是在移動的。其實這也是我一開始就想到過的。但是, 我已經在 MouseUp 事件中把 dragging 這個 bool 值設定為 false 了, 而 MouseMove 事件中會判斷 dragging 的值, 若不為 true 就不會去變更偏移量  (hbImageOffsetX, hbImageOffsetY) 的值 (請參考最後面的程式)。

其實, 這本來也應該不是問題的。比方說, 如果我們就假設使用者是把滑鼠拖到 (1, 7) 這個位置, 有什麼關係? 慣性滾動就慣性滾動吧! 搞不好還可以造成預期之外的特殊效果。

但是事情畢竟是不能打馬虎眼的。如果圖片的拖曳動作從頭到尾只做一次的話, 那麼我就不需要花時間寫這篇文章了。我在前面提過, 由於圖案可能很大, 導致使用者不太可能只拖曳一次就能找到滿意的位置; 我必須假設他一定會拖曳很多次。那麼, 他在第二次進行拖曳時, 由於我所記錄的最後位置 (1, 5) 跟圖片的實際位置 (1, 7) 已經不一致了, 所以這時畫面就會出現一個很突兀的抖動現象。

解決之道

只要弄清楚原因, 後面的事情就很容易解決了。雖然花了我一整天的時間, 其解法卻異常的簡單, 用一句話就足以說明: 「在 MouseUp 事件發生之後, MouseMove 事件丟出來的滑鼠座標就不用理它了」。呃... 如果你無法理解這句話, 那麼只要知道照著去做就可以了 (即使這句話十分的不合邏輯)。但是要怎麼「照著去做」呢? 我看我們就直接看程式吧:

using System.Drawing;
using System.Drawing.Drawing2D;
...

bool dragging = False;
Point originalMousePosition;
int hbImageOffsetX, hbImageOffsetY;
int lastOffsetX, lastOffsetY;

private void Form1_Paint(object sender, PaintEventArgs e)
{
    Point[] points = { new Point(0, 0), new Point(200, 0), new Point(200, 200) };
    GraphicsPath gp = new GraphicsPath();
    gp.AddRectangle(new Rectangle(new Point(0, 0), new Size(200, 200))); // 建立一個矩形路徑
    Bitmap bmp = new Bitmap(Properties.Resources.SmallGreenPattern);
    TextureBrush tb = new TextureBrush(bmp); // 建立一個樣式筆刷
    int offsetX, offsetY;
    if (dragging) // 關鍵就在這個判斷式
    {
        offsetX = lastOffsetX + hbImageOffsetX;
        offsetY = lastOffsetY + hbImageOffsetY;
    }
    else
    {
        offsetX = lastOffsetX;
        offsetY = lastOffsetY;
    }
    tb.TranslateTransform(offsetX, offsetY); // 對樣式筆刷進行位置偏移
    e.Graphics.FillPath(tb, gp) // 實際繪出圖案
    e.Graphics.DrawPath(Pens.Black, gp); // 畫出框線
}

private void Form1_MouseDown(object sender, MouseEventArgs e)
{
    originalMousePosition = e.Location;
    dragging = (e.Button == MouseButtons.Left);
}

private void Form1_MouseMove(object sender, MouseEventArgs e)
{
    if (dragging)
    {
        hbImageOffsetX = e.Location.X - originalMousePosition.X;
        hbImageOffsetY = e.Location.Y - originalMousePosition.Y;
        this.Invalidate(); // 畫面重繪
    }
}

private void Form1_MouseUp(object sender, MouseEventArgs e)
{
    if (dragging)
    {
        dragging = false;
        // 記錄累積的 Offset 值
        lastOffsetX += e.Location.X - originalMousePosition.X;
        lastOffsetY += e.Location.Y - originalMousePosition.Y;
    }
}

在 Form1_Paint 程序中, 原本我在 if (dragging) ... 這一段裡面只有

offsetX = lastOffsetX + hbImageOffsetX;
offsetY = lastOffsetY + hbImageOffsetY;

這兩行而已。因為原本我根本沒有必要在 OnPaint 事件中再去判斷拖曳是否仍在進行中。當使用者放開滑鼠鍵之後, 在 MouseUp 事件中, dragging 這個 bool 值已經被設定為 false。那麼, 就算 MouseMove 事件仍被觸發, 它也應該在判斷 dragging 的值後, 不會再執行到 this.Invalidate(); 這一行指令, 而此時 Form1_Paint() 也根本不會被叫起來執行。

然而事實證明我上面所述的事件發生順序好像跟實際情況並不符合。因為如果說 MouseUp 事件發生在某些 MouseMove 事件之前, 那麼前者的滑鼠座標停留在 (1, 5) 而後者發生在 (1, 7), 這說得通。但奇怪的是, 我在 MouseMove 事件中一開始就去判斷 draggin 這個 bool 值; 就算 MouseMove 事件發生在 MouseUp 之後, 它也不會有機會丟出 (1, 7) 這種座標。

最後, 我只能假設 MouseUp 跟 MouseMove 二者好像是在多工環境之下同時在執行中的執行序, 兩者產生了時間競爭的現象 (但請別問我為什麼每次都發生... 我想破頭也想不通)。換句話說, 當 MouseMove 還在發生中 (dragging 仍為 true), 此時剛好 MouseUp 發生了 (把 dragging 設為 false, 並且記錄最後座標是 (1, 5)), 但是 MouseMove 還沒執行完畢, 而滑鼠還在移動, 所以座標變成了 (1, 7), 然後又觸發了 OnPaint 事件, 所以 Form1_Paint 程序就被執行了。

這種推論雖然符合觀察到的事實, 卻很難說服我。因為電腦的運算速度那麼快, 怎麼可能在 Form1_MouseMove() 程序中短短幾行程式之間造成這種延遲? 何況我不需要把滑鼠拖曳得很快就能發現畫面抖動的問題。

當然還有一種可能性, 那就是每當滑鼠在移動時, 滑鼠驅動程式會很雞婆的對滑鼠座標進行預測的動作。譬如說滑鼠座標明明還在 (1, 5), 但是它觀察到使用者是把滑鼠從 (1, 1) 方向移動過來的, 所以它會 (自作聰明的) 在 MouseMove 事件中丟出 (1, 7) 之類的座標 (視當時滑鼠移動的速度而略有不同)。很可惜的, 它在 MouseUp 事件中丟出的座標卻不會做這種預測, 所以它丟出的座標還是 (1, 5)。在這種情況之下, 像我這樣同時依靠 MouseMove 與 MouseUp 事件以判斷拖曳動作的做法, 就會踫上這種座標不一致的狀況。

解決之道, 就是把剛才那兩行程式稍為改寫如下:

if (dragging) 
{
    offsetX = lastOffsetX + hbImageOffsetX;
    offsetY = lastOffsetY + hbImageOffsetY;
}
else
{
    offsetX = lastOffsetX;
    offsetY = lastOffsetY;
}

經過無數次的測試, 改寫後的程式的確再也沒有任何畫面抖動的現象了。


Dev 2Share @ 點部落