如何用UIKit做一個轉輪(3)
- 2020 年 1 月 9 日
- 筆記
A Digression Into Trigonometry(三角函數的介紹)
在學校里我們都學過如何用度表示角度,並且我們都知道一個圓有360度。但是科學家、工程師以及程式語言的設計者使用一種叫做弧度的單位。
你也許會記得上面drawWheel的程式碼在section#2處使用了表達式2*M_PI來計算圓的大小並分割為幾個扇區。這是因為360度精確的等於2*M_PI。使用這個公式,我們可以推算出1弧度等於180/PI,並且1度等於P1/180弧度。
這就給了我們度與弧度的轉換公式!但是讓我們來形象化的顯示它們之間的關聯。

上面這張圖片顯示了一弧度的「長度」,大約等於57.29度。我們說大約是因為PI是一個無限小數。
還有一種解釋方法。如果你根據上面圖片中紅線對圓的周長進行分割並你把它畫直為一條直線,這條線會跟圓的半徑有相同的長度。
換句話說,如果按一個角度劃分的弧的長度等於半徑,那麼這個角度的大小為1弧度。非常酷!不是么?
另一個重要的情況是,除了半徑的長度,一整個圓還有2*PI個弧度。當你把旋轉應用到轉輪上時會非常有用。你會把圓分割成8個相等的塊,所以每個塊大約0.78弧度,即2*PI/8。

你會從左側觸摸這個圓,按順時針方向轉,所以0弧度應該在左側。下面的圖片顯示了你這個方案中八個扇區的角度和弧度的值。

黑色的小點代表每個扇區在弧度上的中間點。正如你從上邊的drawWheel方法中看到的,為了讓每一個扇區旋轉,你要創建一個仿射轉換affine transform(of type rotation)並且把它設置為容器container的一個property。像這樣:
CGAffineTransform t = CGAffineTransformRotate(container.transform, newValueInRadians); container.transform = t; |
---|
不幸的是,參數newValueInRadians不是你想要轉動到的點,它是要從當前值增加減去的弧度的值。你不能說「旋轉到x弧度」。你必須計算當前值和x的不同,然後加上減去那部分。
例如,你可以創建一個timer來定期的旋轉輪子。第一步讓我們在SMRotaryWheel.h中initWithFrame方法定義的下面添加方法的定義:
-(void)rotate; |
---|
然後,在SMRotaryWheel.m中的initWithFrame 方法中的section#3下邊加上一下程式碼:
// 4 – Timer for rotating wheel [NSTimer scheduledTimerWithTimeInterval:2.0 target:self selector:@selector(rotate) userInfo:nil repeats:YES]; |
---|
最後,在SMRotaryWheel.m的最後(在@end的前邊)增加rotate方法:
– (void) rotate { CGAffineTransform t = CGAffineTransformRotate(container.transform, -0.78); container.transform = t; } |
---|
這裡,我們選擇-0.78是因為它是旋轉一個扇區所要給與的一個必要弧度值,如上所述,我們有8個扇區。
編譯並運行。你會看到輪子沒兩秒完成一次旋轉。這或多或少跟你最後要完成的應用差不多,儘管你還得用到用戶的觸摸。這就把我們帶到了最棘手的部分。
Adding Rotation(添加旋轉)
如果你曾經通過程式碼的方式實現你需要輸入的文字,拿它聽起來太容易了:
l 當用戶輕碰螢幕,存儲弧度的「當前值」
l 每當用戶用手指拖拽,計算新的弧度值並用仿射轉換進行設置
l 當用戶的手指離開螢幕,計算當前選擇的扇區並用旋轉校準輪子的中心
但是正如常言所說,魔鬼都在細節當中。
為了計算輪子所要旋轉的角度,你需要把笛卡爾坐標轉換為極點坐標。這意味著什麼?
當你檢測組件上的一個輕碰時,你可以根據一個「參考點」獲得它的笛卡爾坐標系中的x和y值,這個參考點往往是組件的左上角。在這個方案中,你處在一個「圓圈的」世界,在這個世界裡,極點pole是這個容器container的中心。例如,在下面的圖片中我們說用戶點在輪子的(30,30)這個點上。

用戶觸碰的點和x軸(藍色的線)之間的夾角是多少呢?你需要知道這個值才能計算用戶的手指在輪子上拖拽所划過的角度。這就是要載入到容器container上旋轉的角度。
你要對這個計算方法抓狂和努力了。計算上面說的角度要用到反三角函數,三角函數的反函數。你猜猜看,這個函數返回一個弧度值,這正好就是你所需要的!
但是還有一點難處理的小細節,就是反三角函數的輸入輸出都是PI。如果你記得,我們上面提到過,你的角度範圍是從0到2PI,這不是不能處理,但是你得在以後的計算中注意此事。否則,螢幕上顯示的效果會非常的怪異。
理論講的夠多了,讓我們來看看程式碼!在SMRotaryWheel.h中,添加一個新的屬性property:
@property CGAffineTransform startTransform; |
---|
當用戶觸摸組件時會用來存儲轉換差。為了在用戶觸摸組件時保存角度,我們在SMRotaryWheel.m的頂部添加一個float類型的靜態變數,就寫在@implementation的上面:
static float deltaAngle; |
---|
你也要早先的synthesize這個startTransform屬性:
@synthesize startTransform; |
---|
現在,我們要檢測用戶觸摸了。當用戶輕拍一個組件的實例時,輕拍事件會被beginTrackingWithTouc:touch withEvent:event方法處理。在SMRotaryWheel.m中rotate方法的下邊重寫這個方法:
注意:如果你選擇的是擴展UIView而不是UIControl,這個要重寫的方法是touchesBegan:touches withEven:event。
– (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event { // 1 – Get touch position CGPoint touchPoint = [touch locationInView:self]; // 2 – Calculate distance from center float dx = touchPoint.x – container.center.x; float dy = touchPoint.y – container.center.y; // 3 – Calculate arctangent value deltaAngle = atan2(dy,dx); // 4 – Save current transform startTransform = container.transform; return YES; } |
---|
第一步你要先找到轉輪上觸摸點的笛卡爾坐標,然後計算觸摸點和容器container中心點的差。最後,你要得到反三角函數值並存儲當前的轉換差,這樣每當用戶拖拽輪子時就有一個初始的參考點。
一會你就會看到,你要從這個方法返回YES因為用戶拖拽必須被相應。現在在用戶觸摸開始時你已經保存了這個角度,下一步是根據用戶的拖拽計算弧度。
舉個例子,我們假設用戶觸碰組件的點為(50,50),並拖拽到點(260,115)。

你要計算最後這個點的弧度值並從當用戶觸碰組件時保存的三角形中減去這個值,這個結果就是要傳給仿射變換的弧度值。這些會在組件拋出的每一次拖拽事件中實現,在beginTrackingWithTouch方法下邊增加這個重寫的函數continueTrackingWithTouch:
注意:如果你繼承自UIView,需重寫的方法是 touchesMoved:withEvent:
– (BOOL)continueTrackingWithTouch:(UITouch*)touch withEvent:(UIEvent*)event { CGPoint pt = [touch locationInView:self]; float dx = pt.x – container.center.x; float dy = pt.y – container.center.y; float ang = atan2(dy,dx); float angleDifference = deltaAngle – ang; container.transform = CGAffineTransformRotate(startTransform, -angleDifference); return YES; } |
---|
你會注意到,弧度的計算非常類似與beginTrackingWithTouch方法中所寫程式碼。你也會注意到參數-angleDifference,它會補償負象限的值。
最後,不要忘記在initWithFrame方法中section#4出的提示:這樣輪子才不會自動旋轉。
現在編譯並運行,看見了嗎?你已經做到這一步了!你現在已經有了一個能工作的轉輪的原型,它工作的很好!
儘管還有一些古怪。例如,如果用戶輕拍在靠近輪子中心的一個點上,程式會繼續,大事旋轉會變得有些「跳躍」。這是因為角度的描繪非常的「混亂」,就像下面這張圖。

如果用手指划過輪子的中點,「跳躍」會更嚴重,看下圖。

你可以用當前實現的效果驗證這個問題。然而程式碼是工作的,結果也是正確的。
要解決這個問題,就要藉助真實的輪子用到的解決方案,就像一個較舊但完好的旋轉式撥號盤,撥號盤如果是從較遠的地方轉到中心點,那麼會很難用!你的任務就是忽略太靠近輪子中心的觸摸,通過阻止這樣的觸碰發生時而響應的事件。
要達到這個效果,沒有必要使用反三角函數,勾股定理就足夠了。但是你需要一個幫助函數,calculateDistanceFromCenter,把它添加在SMRotaryWheel.m的頂部,在drawWheel定義的下邊即可:
@interface SMRotaryWheel() … – (float) calculateDistanceFromCenter:(CGPoint)point; |
---|
在continueTrackingWithTouch方法下面進行實現:
– (float) calculateDistanceFromCenter:(CGPoint)point { CGPoint center = CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2); float dx = point.x – center.x; float dy = point.y – center.y; return sqrt(dx*dx + dy*dy); } |
---|
這僅僅計算出了這個觸碰點與中心點的距離。現在在beginTrackingWithTouch:withEvent:方法的section#1處添加程式碼:
// 1.1 – Get the distance from the center float dist = [self calculateDistanceFromCenter:touchPoint]; // 1.2 – Filter out touches too close to the center if (dist < 40 || dist > 100) { // forcing a tap to be on the ferrule NSLog(@"ignoring tap (%f,%f)", touchPoint.x, touchPoint.y); return NO; } } |
---|
這樣,當觸碰點與中心點太近的話,這個觸摸會被簡單的忽略因為你返回了NO,表明組件不會處理此次觸摸事件。
注意:如果你選擇的繼承UIView,你得在touchesMoved:withEvent方法中實現。
你可以根據你輪子的大小自定義可觸摸區域的大小,通過調整section#1.2第一行兩個值(40 和 100),類似下邊這張圖片展示的(藍色區域):

你也許想在continueTrackingWithTouch:withEvent方法中進行同樣的檢查。
下面是比較困難的一部分。你要實現的是一個「come-to-rest」的效果,就是當用戶的手指離開螢幕的時候,輪子會停在當前扇區的中間點。