[譯]UIGestureRecognizer教程:創(chuàng)建自定義手勢(shì)

2023-05-11 10:47 更新

原文鏈接: UIGestureRecognizer教程:創(chuàng)建自定義手勢(shì)

如果是首次訪問(wèn),你可能會(huì)想訂閱我的RSS feed或者在Twitter上粉我。非常感謝你的到來(lái)!

學(xué)習(xí)如何使用自定義 UIGestureRecognizer 來(lái)識(shí)別圓

自定義手勢(shì)可以使app感覺更獨(dú)特,更有活力,從而取悅用戶。如果把基本的點(diǎn)擊、拖移和旋轉(zhuǎn)手勢(shì)比作iOS世界里的通用皮卡,自定義手勢(shì)則是擁有個(gè)性噴漆和水動(dòng)力,且閃閃發(fā)光的hot rods(一種改裝車)。通過(guò)這篇自定義 UIGestureRecognizer 教程你可以了解所有關(guān)于手勢(shì)識(shí)別的知識(shí)!

在這篇教程中我們準(zhǔn)備了一個(gè)有趣的“找茬”小游戲,并通過(guò)使用自定義圓形手勢(shì)來(lái)選擇不一致圖片的方式進(jìn)行互動(dòng)。在這個(gè)過(guò)程中你會(huì)學(xué)到如下幾點(diǎn):

  • 怎樣利用 UIGestureRecognizer 子類所提供的狀態(tài)和回調(diào)機(jī)制來(lái)簡(jiǎn)化手勢(shì)檢測(cè)。
  • 怎樣將一個(gè)觸摸點(diǎn)集合擬合成一個(gè)圓。
  • 怎樣“模糊”識(shí)別特殊的形狀,因?yàn)橛檬种咐L制出來(lái)的形狀通常是不精確的。

注意:本教程假定你已經(jīng)知道手勢(shì)識(shí)別是如何工作的,且知道如何在app中使用系統(tǒng)定義手勢(shì)。如果想速成,請(qǐng)看本站的UIGestureRecognizer教程。

開始

MatchItUp 向用戶展示4張圖片,3張是一致的,另外1張和其他的略有不同。用戶的任務(wù)就是通過(guò)用手指在上面畫一個(gè)圓圈的方式找出不同的那張:

MatchItUp! 游戲

在這里下載教程初始項(xiàng)目

構(gòu)建并運(yùn)行app;你會(huì)看到4張圖片,但是你還不能選出不同的那張。你的任務(wù)就是給這個(gè)游戲添加一個(gè)自定義手勢(shì)識(shí)別器。當(dāng)用戶在一個(gè)圖片周圍畫了一個(gè)圓,自定義手勢(shì)識(shí)別器就會(huì)檢測(cè)到。如果他們剛好在不同的那張上畫了一個(gè)圓,就贏了!

添加一個(gè)自定義手勢(shì)識(shí)別器

打開File\New\File… 然后選擇 iOS\Source\Cocoa Touch Class模板來(lái)創(chuàng)建一個(gè)名為 CircleGestureRecognizerUIGestureRecognizer子類。注意選擇Swift 。然后點(diǎn)擊Next然后再點(diǎn)擊Create。

為了讓手勢(shì)識(shí)別器生效,它必須連接到響應(yīng)鏈中的某個(gè)視圖。當(dāng)用戶點(diǎn)擊屏幕時(shí),觸摸事件在視圖堆棧中轉(zhuǎn)發(fā),每個(gè)視圖上的手勢(shì)識(shí)別器都可以處理這些觸摸事件。打開GameViewController.swift然后為手勢(shì)識(shí)別器添加一個(gè)實(shí)例變量:

var circleRecognizer: CircleGestureRecognizer!

下一步,在 viewDidLoad 中添加如下代碼:

circleRecognizer = CircleGestureRecognizer(target: self, action: "circled:")
view.addGestureRecognizer(circleRecognizer)

這段代碼創(chuàng)建手勢(shì)識(shí)別器并將它加到主視圖上。

等等… 如果目標(biāo)是讓用戶圈出不同的那張圖片,為什么不直接把識(shí)別器添加每張圖片上,而是添加到主視圖呢?

好問(wèn)題——很高興能對(duì)其進(jìn)行解答!:]

當(dāng)構(gòu)建一個(gè)手勢(shì)時(shí),一定要對(duì)用戶界面的不精確性進(jìn)行補(bǔ)償。如果你曾經(jīng)嘗試過(guò)在觸摸屏上很小的一個(gè)框內(nèi)簽上你的名字,你就會(huì)明白我的意思!:]

當(dāng)把識(shí)別器放在整個(gè)view上時(shí),它可以讓用戶在圖片的框之外更輕松滴開始和繼續(xù)某個(gè)手勢(shì),最終,你的識(shí)別器也會(huì)減輕那些不能畫出完美圓用戶的壓力。

構(gòu)建然后運(yùn)行app;盡管你已經(jīng)創(chuàng)建了一個(gè) UIGestureRecognizer 子類。但是你還沒有添加任何代碼所以很顯然它將只能識(shí)別…0個(gè)手勢(shì)!為了使其生效,需要為手勢(shì)識(shí)別器實(shí)現(xiàn)一個(gè)手勢(shì)識(shí)別狀態(tài)機(jī)。

手勢(shì)識(shí)別狀態(tài)機(jī)

所有用戶操作中最簡(jiǎn)單的是點(diǎn)擊;用戶放下手指然后抬起。對(duì)于這個(gè)時(shí)間手勢(shì)識(shí)別器會(huì)調(diào)用兩個(gè)方法: touchesBegan(_:withEvent:)touchesEnded(_:withEvent:)

在簡(jiǎn)單的點(diǎn)擊手勢(shì)中,這兩個(gè)方法和手勢(shì)識(shí)別器的兩個(gè)狀態(tài) .Began.Ended 對(duì)應(yīng):

基礎(chǔ)的點(diǎn)擊手勢(shì)識(shí)別器

為了看到這個(gè)動(dòng)作的效果,你需要在 CircleGestureRecognizer 類中實(shí)現(xiàn)這個(gè)狀態(tài)機(jī)。

最先!在 CircleGestureRecognizer.swift 的最上面添加如下的 import。

import UIKit.UIGestureRecognizerSubclass

UIGestureRecognizerSubclassUIKit 中的一個(gè)公共頭文件,但是沒有包含在 UIKit 頭文件中。因?yàn)槟阈枰?state 屬性,所以導(dǎo)入這個(gè)頭文件是必須的,否則, 它就只是 UIGestureRecognizer 中的一個(gè)只讀屬性。

現(xiàn)在在同一個(gè)類中添加如下代碼:

override func touchesBegan(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesBegan(touches, withEvent: event)
  state = .Began
}

 
override func touchesEnded(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesEnded(touches, withEvent: event)
  state = .Ended
}

如果此時(shí)運(yùn)行app然后點(diǎn)擊屏幕,app會(huì)因?yàn)槟銢]有對(duì)這個(gè)手勢(shì)進(jìn)行處理而崩潰。

GameViewController.swift文件中的類添加如下代碼:

func circled(c: CircleGestureRecognizer) {
  if c.state == .Ended {
    let center = c.locationInView(view)
    findCircledView(center)
  }
}

當(dāng)手勢(shì)識(shí)別器的狀態(tài)改變時(shí)它的 target-action 就會(huì)被觸發(fā)。當(dāng)手指觸碰到屏幕時(shí), touchesBegan(_:withEvent) 觸發(fā)。手勢(shì)識(shí)別器將其狀態(tài)置為 .Began ,然后自動(dòng)調(diào)用 target-action。當(dāng)手指離開屏幕時(shí), touchesEnded(_:withEvent) 將其狀態(tài)置為 .Ended,然后再次調(diào)用 target-action 。

早前在對(duì)手勢(shì)識(shí)別器進(jìn)行設(shè)置時(shí),你已經(jīng)把 circled(_:) 方法指定為它的 target-action 。方法的實(shí)現(xiàn)中使用提供的 findCircledView(_:) 方法來(lái)檢測(cè)點(diǎn)擊的是哪一張圖片。

構(gòu)建并運(yùn)行app;點(diǎn)擊某張圖片然后選中它。游戲檢查你的選擇是否正確然后進(jìn)入下一輪:

點(diǎn)擊選擇某個(gè)圖片

處理多點(diǎn)觸摸

現(xiàn)在你已經(jīng)有一個(gè)可用的點(diǎn)擊手勢(shì)識(shí)別器,對(duì)嗎?不要這么著急下結(jié)論,手指可多著呢!:] 注意方法的名字中包含的是 “touches”——復(fù)數(shù)。手勢(shì)識(shí)別器可以檢測(cè)多手指手勢(shì),但是游戲的圓形手勢(shì)意味著我們只識(shí)別單手指手勢(shì)。

注:圖片中的意思是我也差點(diǎn)忘了…

你需要檢查是否只有一個(gè)手指觸摸了屏幕。

打開CircleGestureRecognizer.swift文件然后修改 touchesBegan(_:) 使 touches 參數(shù)只允許包含一個(gè) UITouch 對(duì)象:

override func touchesBegan(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesBegan(touches, withEvent: event)
  if touches.count != 1 {
    state = .Failed
  }
  state = .Began
}

這里要想你介紹手勢(shì)識(shí)別器的第三種狀態(tài):.Failed。.Ended 表示手勢(shì)成功完成,而.Failed表示用戶的手勢(shì)不是你想要的。

即時(shí)把狀態(tài)機(jī)置為終止?fàn)顟B(tài)是非常重要的,比如.Failed,這樣其他等待響應(yīng)的手勢(shì)識(shí)別器才有機(jī)會(huì)解讀觸摸事件。

再次構(gòu)建并運(yùn)行app;嘗試多手指點(diǎn)擊和單手指點(diǎn)擊。這次只有單手指點(diǎn)擊才能對(duì)圖片進(jìn)行選擇。

檢測(cè)圓

“等等,”你喊道。“點(diǎn)擊跟圓根本不是一回事??!”

當(dāng)然,如果你想了解其全部技術(shù)細(xì)節(jié)的話,一個(gè)點(diǎn)就是一個(gè)半徑為0的圓。但在這里并沒有什么意義;用戶必須圈住圖片,選擇才有效。

如果要檢測(cè)圓,你需要收集用戶移動(dòng)手指時(shí)所經(jīng)過(guò)的點(diǎn),然后看它們是否組成了一個(gè)圓。

看起來(lái)集合可以非常完美滴勝任這個(gè)工作。

CircleGestureRecognizer類的頂部添加如下的實(shí)例變量:

private var touchedPoints = [CGPoint]() // 記錄歷史點(diǎn)

用它來(lái)記錄用戶觸摸的點(diǎn)。

現(xiàn)在在CircleGestureRecognizer類中添加如下方法:

override func touchesMoved(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesMoved(touches, withEvent: event)

 
  // 1
  if state == .Failed {
    return
  }

 
  // 2
  let window = view?.window
  if let touches = touches as? Set<UITouch>, loc = touches.first?.locationInView(window) {
    // 3
    touchedPoints.append(loc)
    // 4
    state = .Changed
  }
}

在初始的觸摸事件之后用戶每移動(dòng)手指都會(huì)觸發(fā) touchesMoved(_:withEvent:) 。每觸發(fā)一次就順序執(zhí)行用數(shù)字標(biāo)記的代碼塊:

  1. Apple 建議首先檢查手勢(shì)是否已經(jīng)失效;如果已經(jīng)失效,就不要繼續(xù)處理其他的觸摸事件了。觸摸事件被緩存在事件隊(duì)列中然后被串行處理。如果用戶在觸摸時(shí)移動(dòng)足夠快,手勢(shì)失效后仍然會(huì)有觸摸事件在等待和被處理。
  2. 為了方便后期的數(shù)學(xué)計(jì)算,把檢測(cè)到的點(diǎn)轉(zhuǎn)換為窗口坐標(biāo)。因?yàn)檫@樣可以更方便滴記錄與任何具體視圖無(wú)關(guān)的觸摸事件,從而使用戶可以在超出某張圖片范圍的地方畫圓時(shí)仍然可以選擇這張圖片。
  3. 把點(diǎn)添加到數(shù)組中。
  4. 把狀態(tài)更新為 .Changed。這會(huì)有調(diào)用 target-action 的副作用。

.Changed 是另外一個(gè)添加到狀態(tài)機(jī)的狀態(tài)。每當(dāng)手勢(shì)識(shí)別器的觸摸事件發(fā)生變化時(shí)都會(huì)轉(zhuǎn)換到.Changed狀態(tài);這些變化包括:手指移動(dòng)、按下和抬起。

下面是添加了.Changed狀態(tài)的狀態(tài)機(jī):

現(xiàn)在你已經(jīng)獲得了所有的點(diǎn),如何去確定這些點(diǎn)是否組成一個(gè)圓呢?

驗(yàn)證這些點(diǎn)

首先,把如下的變量添加到 CircleGestureRecognizer.swift中類的頂部:

var fitResult = CircleResult() // 有關(guān)于這條路徑有多像圓的一些信息
var tolerance: CGFloat = 0.2 // 圓的容錯(cuò)值
var isCircle = false

這些變量會(huì)幫助你決定在可容忍范圍內(nèi)這些店是否組成一個(gè)圓。

修改 touchesEnded(_:withEvent:) 讓它變成下面的樣子:

override func touchesEnded(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesEnded(touches, withEvent: event)

 
  // 現(xiàn)在用戶已經(jīng)完成了觸控,確定這條路徑是否是一個(gè)圓
  fitResult = fitCircle(touchedPoints)

 
  isCircle = fitResult.error <= tolerance
  state = isCircle ? .Ended : .Failed
}

這里我們稍微作了點(diǎn)弊,使用了一個(gè)事先做好的圓形檢測(cè)器。你可以看一看CircleFit.swift,但我只會(huì)對(duì)它的內(nèi)部細(xì)節(jié)做一點(diǎn)點(diǎn)描述。檢測(cè)器的主要目的是將記錄的點(diǎn)擬合成一個(gè)圓。error 值代表了目前的路徑和真正的圓形偏離了多少,而tolerance的存在則是因?yàn)槟悴荒芷谕脩裟墚嫵鲆粋€(gè)完美的圓。如果error的值在tolerance的范圍內(nèi),手勢(shì)識(shí)別器將狀態(tài)置為.Ended;否則將狀態(tài)置為.Failed。

此時(shí)如果馬上構(gòu)建并運(yùn)行app,游戲不會(huì)正常工作,因?yàn)榇藭r(shí)手勢(shì)識(shí)別器仍然按點(diǎn)擊來(lái)識(shí)別手勢(shì)。

回到GameViewController.swift,然后將circled(_:)修改成如下的樣子:

這func circled(c: CircleGestureRecognizer) {
  if c.state == .Ended {
    findCircledView(c.fitResult.center)
  }
}

這里使用計(jì)算得到的圓的中心點(diǎn)來(lái)確定用戶在哪個(gè)視圖畫圈了,而不是使用最后觸摸的那個(gè)點(diǎn)。

構(gòu)建并運(yùn)行app;嘗試使用你的手玩這個(gè)游戲——相當(dāng)蛋疼。讓app識(shí)別出你畫的圓不是那么簡(jiǎn)單是不是?剩下的就是如何在數(shù)學(xué)理論和不精確的現(xiàn)實(shí)世界之間搭建一座橋梁的問(wèn)題了。

畫軌跡

因?yàn)樵诋媹A的過(guò)程中很難準(zhǔn)確地知道接下來(lái)該怎么畫,所以你需要把用戶手指移動(dòng)的路徑繪制出來(lái)。iOS已經(jīng)在Core Graphics中包含了你需要的功能。

把下面的實(shí)例變量聲明添加到CircleGestureRecognizer.swift

var path = CGPathCreateMutable() // 運(yùn)行 CGPath - 輔助繪制

這個(gè)變量提供了一個(gè)可變的CGPath對(duì)象,用于繪制路徑。

把下面的代碼添加到touchesBegan(_:withEvent:)的底部:

let window = view?.window
if let touches = touches as? Set<UITouch>, loc = touches.first?.locationInView(window) {
  CGPathMoveToPoint(path, nil, loc.x, loc.y) // 開始構(gòu)建路徑
}

這段代碼保證路徑從觸摸開始的位置開始。

現(xiàn)在把下面的代碼添加到touchesMoved(_:withEvent:)底部if let代碼塊的touchedPoints.append(loc)下:

CGPathAddLineToPoint(path, nil, loc.x, loc.y)

每當(dāng)手指移動(dòng)時(shí),你通過(guò)畫線的方式把新的點(diǎn)添加到路徑中。不要擔(dān)心直線的部分;因?yàn)辄c(diǎn)和點(diǎn)之間距離很近,所以在你畫的路徑最終看起來(lái)會(huì)相當(dāng)流暢。

為了使路徑可見,需要將它繪制到游戲視圖中。在CircleDrawView視圖層級(jí)中已經(jīng)有這樣一個(gè)視圖了。

如果要將路徑展示在這個(gè)視圖中,需要把下面的代碼添加到GameViewController.swiftcircled(_:)方法的底部。

if c.state == .Began {
  circlerDrawer.clear()
}
if c.state == .Changed {
  circlerDrawer.updatePath(c.path)
}

這段代碼當(dāng)新的手勢(shì)開始時(shí)會(huì)清除視圖中的內(nèi)容,然后跟蹤用戶的手指,使用黃色的線畫出路徑。

構(gòu)建并運(yùn)行app;嘗試在屏幕上畫圓然后觀察它是如何工作的:

酷斃了!但你是否有注意到當(dāng)畫第二個(gè)或者第三個(gè)圓時(shí)出現(xiàn)的搞笑情況呢?

盡管在變成.Began狀態(tài)時(shí)有調(diào)用circlerDrawer.clear(),但是每次做一個(gè)新手勢(shì)時(shí),之前的并沒有被清除掉。這只能意味著:是時(shí)候?yàn)槟闶謩?shì)識(shí)別器的狀態(tài)機(jī)引入新的動(dòng)作:reset() 了。

復(fù)位動(dòng)作

你需要在touchesEnded 之后 touchesBegan 之前調(diào)用 reset() 。這可以讓手勢(shì)識(shí)別器清除它的狀態(tài)然后重新開始。

把下面的方法添加到CircleGestureRecognizer.swift中:

override func reset() {
  super.reset()
  touchedPoints.removeAll(keepCapacity: true)
  path = CGPathCreateMutable()
  isCircle = false
  state = .Possible
}

在這個(gè)方法里你清空了觸摸點(diǎn)集合,然后把 path 設(shè)置為新的路徑。同時(shí),你把狀態(tài)設(shè)置為 .Possible,這個(gè)狀態(tài)表示觸摸事件沒有被匹配到或者手勢(shì)失效了。

新的狀態(tài)機(jī)看起來(lái)會(huì)像下面這樣:

再次構(gòu)建并運(yùn)行app;這一次,每次觸摸時(shí)視圖內(nèi)容(以及手勢(shì)識(shí)別器的狀態(tài))都會(huì)被清除。

數(shù)學(xué)原理

CircleFit 內(nèi)部到底發(fā)生了什么?然后為什么有的時(shí)候會(huì)把一些類似C、S的奇怪形狀當(dāng)作圓形?

僅僅是一條很短的線就被當(dāng)作一個(gè)圓

大家應(yīng)該記得在高中的時(shí)候就學(xué)過(guò)圓的方程:sqrt{x^2+y^2} = r^2。如果用戶畫了個(gè)圓,那么所有的觸摸點(diǎn)都應(yīng)該完全符合這個(gè)方程式:

或者更準(zhǔn)確滴說(shuō),因?yàn)樽R(shí)別器需要識(shí)別出所有的圓,而不是以起始點(diǎn)為中心的圓,方程就應(yīng)該是 sqrt{(x-x_c)^2+(y-y_c)^2} = r^2。當(dāng)手勢(shì)結(jié)束時(shí),你所擁有的只是一堆僅僅有x和y的點(diǎn)集合。剩下的就是確定中心點(diǎn) (x_c, y_c) 以及半徑 (r)

以xc,yc為中心的圓

確定中心點(diǎn)和半徑的方法有好幾種,本教程采用的方法改編自Nikolai Chernov用C++實(shí)現(xiàn)的Taubin擬合方法。流程如下:

  1. 首先,同時(shí)計(jì)算所有點(diǎn)的平均值來(lái)猜測(cè)圓的質(zhì)心(也就是所有點(diǎn)的x和y坐標(biāo))。如果是標(biāo)準(zhǔn)的圓,所有點(diǎn)的質(zhì)心就會(huì)是圓的圓心。如果這些點(diǎn)沒有組成標(biāo)準(zhǔn)的圓,那么計(jì)算出來(lái)的圓心就會(huì)有所偏離:


*圓心的猜測(cè)從一開始就是以所有點(diǎn)為基礎(chǔ)的*
  1. 下一步計(jì)算力矩。假定圓心是有質(zhì)量的。力矩就是用來(lái)計(jì)算觸摸路徑上的每個(gè)點(diǎn)對(duì)這個(gè)質(zhì)量產(chǎn)生的力。
  2. 然后你把這個(gè)力矩值代入一個(gè)特征多項(xiàng)式,它是用來(lái)尋找“真正中心點(diǎn)”的根本。力矩同時(shí)用來(lái)計(jì)算半徑。這個(gè)數(shù)學(xué)理論不在本教程的范圍之內(nèi),而其核心思想就是用來(lái)保證所有點(diǎn)在 sqrt{(x-x_c)^2+(y-y_c)^2} = r^2 方程式中x_cy_cr 的值都相同數(shù)學(xué)方法。
  3. 最后,你計(jì)算出一個(gè)均方根誤差來(lái)做擬合。這是用來(lái)衡量實(shí)際的點(diǎn)和圓軌跡偏離多少的方法:

    藍(lán)杠表示誤差,或者和紅色擬合圓和點(diǎn)的差距

所以他們說(shuō)數(shù)學(xué)很難!哼!

腦袋疼么?其實(shí)這個(gè)又臭又長(zhǎng)的算法只是在所有點(diǎn)的中心擬合一個(gè)圓,然后根據(jù)每個(gè)點(diǎn)和計(jì)算得到的圓心的距離得到半徑。然后再計(jì)算每個(gè)點(diǎn)和計(jì)算得到的圓之間的誤差值。如果誤差很小,就假定用戶畫了一個(gè)圓。

但是這個(gè)算法在路徑組成對(duì)稱圓形,比如C和S這種計(jì)算得到的誤差值很小的情況,或者路徑組成很短的弧或線,而這些點(diǎn)被當(dāng)作一個(gè)大得多的圓上的一小段的情況時(shí)就會(huì)出錯(cuò)。

大部分的點(diǎn)都在圓上,其他的點(diǎn)足夠?qū)ΨQ從而使它們能“互相抵消”

這張圖展示了一條線是如何被擬合成一個(gè)圓的,因?yàn)檫@些點(diǎn)看起來(lái)像圓上的一條弧

調(diào)試?yán)L制

所以為了弄清楚這個(gè)奇怪的手勢(shì)里都發(fā)生了什么,你可以把擬合圓在屏幕上繪制出來(lái)。

CircleDrawView.swiftdrawDebug 的值設(shè)置為 true

var drawDebug = true // 設(shè)置成true將展示擬合相關(guān)的其他信息

這段代碼會(huì)把擬合圓的一些其他信息繪制到屏幕上。

如果要將擬合細(xì)節(jié)更新到視圖上,把如下代碼分支添加到 GameViewController.swift 中的 circled(_:) 方法:

if c.state == .Ended || c.state == .Failed || c.state == .Cancelled {
  circlerDrawer.updateFit(c.fitResult, madeCircle: c.isCircle)
}

再次構(gòu)建并運(yùn)行app;畫一個(gè)圓形路徑,當(dāng)你抬起手指時(shí),擬合圓就被繪制到屏幕上,如果擬合成功就是綠色,擬合失敗就是紅色:

接下來(lái)會(huì)講一點(diǎn)點(diǎn)其他方面的事情。

識(shí)別手勢(shì),而不是路徑

回到被標(biāo)記錯(cuò)的形狀,為什么這些非圓形手勢(shì)會(huì)被處理?擬合在兩種情況下顯然會(huì)出錯(cuò):當(dāng)繪制的形狀在圓內(nèi)部有點(diǎn),以及繪制的形狀不是一個(gè)完整的圓時(shí)。

檢查圓內(nèi)部

對(duì)于像類似S,漩渦,數(shù)字8等等對(duì)稱的形狀。擬合得到的誤差非常小,但是很顯然它們都不是圓。這就是數(shù)學(xué)近似法和一個(gè)可用手勢(shì)之間的差距。一個(gè)明顯的修復(fù)方式就是排除所有在圓內(nèi)部存在點(diǎn)的路徑。

你可以通過(guò)檢查所有的觸摸點(diǎn),看是否有點(diǎn)是在擬合圓內(nèi)部的方式來(lái)解決這個(gè)問(wèn)題。

把下面的輔助方法加到 CircleGestureRecognizer.swift中:

private func anyPointsInTheMiddle() -> Bool {
  // 1
  let fitInnerRadius = fitResult.radius / sqrt(2) * tolerance
  // 2
  let innerBox = CGRect(
    x: fitResult.center.x - fitInnerRadius,
    y: fitResult.center.y - fitInnerRadius,
    width: 2 * fitInnerRadius,
    height: 2 * fitInnerRadius)

 
  // 3
  var hasInside = false
  for point in touchedPoints {
    if innerBox.contains(point) {
      hasInside = true
      break
    }
  }

 
  return hasInside
}

這段代碼對(duì)根據(jù)圓擬合出來(lái)的一個(gè)較小矩形禁區(qū)進(jìn)行檢查。如果有某個(gè)點(diǎn)出現(xiàn)在這個(gè)矩形中那么這個(gè)手勢(shì)就失效了。上述代碼做了如下的事情:

  1. 計(jì)算出一個(gè)較小的禁區(qū)。變量tolerance 將為散亂,但合理的圓提供足夠的空間,但是也有足夠的控件來(lái)排除那些正中間有點(diǎn)的非圓形狀。
  2. 為了簡(jiǎn)化代碼,這段代碼只是在圓心構(gòu)建了一個(gè)小方塊。
  3. 這段代碼會(huì)遍歷所有的點(diǎn),然后檢查是否有點(diǎn)在 innerBox 內(nèi)。

下一步,修改 touchesEnded(_:withEvent:) ,把如下代碼添加到 isCircle 的判斷中:

override func touchesEnded(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesEnded(touches, withEvent: event)

 
  // 用戶停止觸摸,判斷路徑是否組成了一個(gè)圓
  fitResult = fitCircle(touchedPoints)

 
  // 保證沒有點(diǎn)在圓的中間
  let hasInside = anyPointsInTheMiddle()

 
  isCircle = fitResult.error <= tolerance && !hasInside

 
  state = isCircle ? .Ended : .Failed
}

這段代碼使用這個(gè)檢測(cè)方法來(lái)判斷圓中間是否有點(diǎn),如果有,那么就檢測(cè)不到圓。

構(gòu)建并運(yùn)行。嘗試畫一個(gè)‘S’形狀,你會(huì)發(fā)現(xiàn)它將不能被識(shí)別。太贊了!:]

處理小圓弧

現(xiàn)在你已經(jīng)對(duì)非圓的弧形進(jìn)行了處理,那些被當(dāng)作超大圓一部分的討厭短弧怎么辦?如果你在調(diào)試?yán)L制時(shí)觀察過(guò),路徑(黑框內(nèi))和擬合圓的尺寸差距是非常巨大的:

被識(shí)別成圓的路徑至少要和圓本上的尺寸差不太多:

修復(fù)這個(gè)問(wèn)題只需要簡(jiǎn)單滴把路徑的大小和擬合圓的大小做比較就可以了。

把下面的輔助方法添加到 CircleGestureRecognizer.swift中:

private func calculateBoundingOverlap() -> CGFloat {
  // 1
  let fitBoundingBox = CGRect(
    x: fitResult.center.x - fitResult.radius,
    y: fitResult.center.y - fitResult.radius,
    width: 2 * fitResult.radius,
    height: 2 * fitResult.radius)
  let pathBoundingBox = CGPathGetBoundingBox(path)

 
  // 2
  let overlapRect = fitBoundingBox.rectByIntersecting(pathBoundingBox)

 
  // 3
  let overlapRectArea = overlapRect.width * overlapRect.height
  let circleBoxArea = fitBoundingBox.height * fitBoundingBox.width

 
  let percentOverlap = overlapRectArea / circleBoxArea
  return percentOverlap
}

這個(gè)方法計(jì)算出用戶的路徑和擬合圓有多少是重疊的:

  1. 找出擬合圓和用戶路徑的包圍盒。因?yàn)樗械挠|摸點(diǎn)都被做為 CGMutablePath 路徑變量的一部分,所以可以使用 CGPathGetBoundingBox 方法來(lái)處理棘手的數(shù)學(xué)問(wèn)題。
  2. 使用 CGRectrectByIntersecting 方法來(lái)計(jì)算出兩個(gè)矩形路徑的重疊部分。
  3. 找出兩個(gè)包圍盒面積重疊部分的百分比。如果是一個(gè)良好的圓形手勢(shì),那么這個(gè)百分比會(huì)在80%-100%的范圍內(nèi)。在短弧的情況下,這個(gè)百分比會(huì)非常非常??!

下一步,修改 touchesEnded(_:withEvent:) 中對(duì) isCircle 的判斷,如下:

let percentOverlap = calculateBoundingOverlap()
isCircle = fitResult.error <= tolerance && !hasInside && percentOverlap > (1-tolerance)

構(gòu)建并運(yùn)行app;只有合理的圓形才能通過(guò)測(cè)試。你可以想盡辦法愚弄它!:]

處理Cancelled狀態(tài)

你是否有注意到之前測(cè)試?yán)L制這節(jié)對(duì) .Cancelled 的檢查?觸摸會(huì)在有系統(tǒng)告警、在手勢(shì)識(shí)別器被某個(gè)代理明確取消、在觸摸中途被置為不可用時(shí)被取消掉。除了更新狀態(tài)機(jī),不需要為圓形識(shí)別器做更多的事情。把下面的代碼片段添加到CircleGestureRecognizer.swift

override func touchesCancelled(touches: Set<NSObject>!, withEvent event: UIEvent!) {
  super.touchesCancelled(touches, withEvent: event)
  state = .Cancelled // 提前設(shè)置為取消狀態(tài)
}

這段代碼在觸摸事件被取消時(shí)將 state 置為 .Cancelled

處理其他觸摸事件

當(dāng)程序運(yùn)行時(shí),點(diǎn)擊New Set。發(fā)現(xiàn)什么了么?對(duì),按鈕不能用了!這是因?yàn)槭謩?shì)識(shí)別器吃掉了所有的點(diǎn)擊事件!

使手勢(shì)識(shí)別器與其他控件正常交互的方式有幾種。首選的方式是使用 UIGestureRecognizerDelegate 來(lái)重寫默認(rèn)行為。

打開 GameViewController.swift,在 viewDidLoad(_:) 中把手勢(shì)識(shí)別器的 delegate 設(shè)置為 self

circleRecognizer.delegate = self

現(xiàn)在在文件的底部添加如下的擴(kuò)展來(lái)實(shí)現(xiàn)代理方法:

extension GameViewController: UIGestureRecognizerDelegate {
  func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldReceiveTouch touch: UITouch) -> Bool {
    // 允許點(diǎn)擊按鈕
    return !(touch.view is UIButton)
  }
}

這段代碼阻止手勢(shì)識(shí)別器去識(shí)別按鈕上的觸摸事件;而繼續(xù)讓按鈕本身去處理觸摸。代理方法還有好幾個(gè),這些代理方法可以用來(lái)自定義手勢(shì)識(shí)別器在視圖層級(jí)中的行為方式。再次構(gòu)建并運(yùn)行app;點(diǎn)擊按鈕就可以正常使用了。

細(xì)心打磨這個(gè)游戲

剩下的就是處理交互細(xì)節(jié),讓游戲看起來(lái)是精心打磨過(guò)的。

首先,在某個(gè)圖片被圈中之后你要阻止用戶繼續(xù)和視圖交互。否則,在等待新一組圖片出現(xiàn)時(shí)路徑仍然會(huì)繼續(xù)更新。

打開GameViewController.swift,把如下代碼添加到 selectImageViewAtIndex(_:) 的底部:

circleRecognizer.enabled = false

現(xiàn)在在 startNewSet(_:) 方法的底部讓手勢(shì)識(shí)別器重新生效,從而繼續(xù)處理下一輪:

circleRecognizer.enabled = true

下一步,把如下代碼加到 circled(_:).Began 分支中:

if c.state == .Began {
  circlerDrawer.clear()
  goToNextTimer?.invalidate()
}

這段代碼添加了一個(gè)計(jì)時(shí)器,這個(gè)計(jì)時(shí)器會(huì)在短暫的延遲后清除掉路徑,從而使用戶在延遲時(shí)間內(nèi)還能重新嘗試。

同時(shí)把如下代碼添加在 circled(_:) 方法的最終狀態(tài)檢測(cè)中:

if c.state == .Ended || c.state == .Failed || c.state == .Cancelled {
  circlerDrawer.updateFit(c.fitResult, madeCircle: c.isCircle)
  goToNextTimer = NSTimer.scheduledTimerWithTimeInterval(afterGuessTimeout, target: self, selector: "timerFired:", userInfo: nil, repeats: false)
}

這段代碼在手勢(shì)識(shí)別器的狀態(tài)變成結(jié)束、失敗或者取消時(shí)設(shè)置一個(gè)短時(shí)間內(nèi)啟動(dòng)的計(jì)時(shí)器。

最后,在 GameViewController 中添加如下方法:

func timerFired(timer: NSTimer) {
  circlerDrawer.clear()
}

這段代碼在計(jì)時(shí)器啟動(dòng)時(shí)清除圓形,這樣用戶就知道畫另外一個(gè)圓形來(lái)再次嘗試。

構(gòu)建并運(yùn)行app;如果手勢(shì)不是近似的一個(gè)圓,你會(huì)發(fā)現(xiàn)路徑在短暫的延遲后就會(huì)自動(dòng)被清除掉。

現(xiàn)在該去哪?

你可以在這里下載教程的完整項(xiàng)目。

現(xiàn)在你已經(jīng)為你的游戲做了一個(gè)簡(jiǎn)單但功能強(qiáng)大的圓形手勢(shì)識(shí)別器。你可以把這個(gè)概念進(jìn)行延伸來(lái)識(shí)別其他形狀,甚至可以自定義圓擬合算法來(lái)適應(yīng)其他需求。

如果想要了解更多,可以查閱蘋果官方文檔中關(guān)于Gesture Recognizers的章節(jié)。

如果你對(duì)本教程有任何疑問(wèn)和評(píng)論,請(qǐng)?jiān)谡搲路降脑u(píng)論區(qū)自由發(fā)言!

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)