Denna handledning kommer att lära dig hur man implementerar en avancerad ritningsalgoritm för jämn, frihandritning på iOS-enheter. Läs vidare!
Touch är det primära sättet en användare kommer att interagera med iOS-enheter. En av de mest naturliga och uppenbara funktioner som dessa enheter förväntas ge är att tillåta användaren att dra på skärmen med fingret. Det finns många frihandsteckning och noteringsprogram för närvarande i App Store, och många företag frågar även kunder att signera en iDevice när de gör inköp. Hur fungerar dessa applikationer faktiskt? Låt oss sluta och tänka på en minut om vad som händer "under huven".
När en användare rullar en tabellvy, klämmer fast för att förstora en bild eller drar en kurva i en målningsapp, uppdateras enhetens display snabbt (säg 60 gånger i sekundet) och programlöpningen är ständigt provtagning Placeringen av användarens finger (er). Under denna process måste den analoga inmatningen av ett finger som drar över skärmen omvandlas till en digital uppsättning punkter på skärmen, och denna omvandlingsprocess kan utgöra betydande utmaningar. I samband med vår målningsapp har vi ett "data-passande" problem på våra händer. Eftersom användaren skriker sig glatt på enheten, måste programmeraren väsentligen interpolera saknad analog information ("connect-the-dots") som har gått vilse bland de samplade beröringspunkter som IOS rapporterade till oss. Vidare måste denna interpolering ske på ett sådant sätt att resultatet är en stroke som framstår som kontinuerlig, naturlig och slät för slutanvändaren, som om han hade skissat med en penna på en anteckningsblock av papper.
Syftet med denna handledning är att visa hur frihandsteckning kan implementeras på iOS, med utgångspunkt från en grundläggande algoritm som utför raklinjeinterpolering och avancera till en mer sofistikerad algoritm som närmar sig kvaliteten som erbjuds av välkända applikationer som Penultimate. Som att skapa en algoritm som fungerar är inte tillräckligt svår, måste vi också se till att algoritmen fungerar bra. Som vi kommer att se kan ett naivt teckentillverkning leda till en app med betydande prestationsfrågor som kommer att göra ritning besvärlig och så småningom oanvändbar.
Jag antar att du inte är helt ny för iOS-utveckling, så jag har skumrat över stegen att skapa ett nytt projekt, lägga till filer i projektet etc. Förhoppningsvis finns det inget för svårt här ändå, men bara om Full projektkod finns tillgänglig för dig att ladda ner och leka med.
Starta ett nytt Xcode iPad-projekt baserat på "Enkel visningsprogram"Mall och namnge det"FreehandDrawingTut". Var säker på att aktivera automatisk referensräkning (ARC), men att avmarkera Storyboards och enhetstester. Du kan göra detta projekt antingen en iPhone eller Universal app beroende på vilka enheter du har tillgång till för testning.
Fortsätt sedan och välj "FreeHandDrawingTut" -projektet i Xcode Navigator och se till att endast porträttorienteringen stöds:
Om du ska distribuera till iOS 5.x eller tidigare kan du ändra orienteringsstöd på följande sätt:
- (BOOL) shouldAutorotateToInterfaceOrientation: (UIInterfaceOrientation) interfaceOrientation return (interfaceOrientation == UIInterfaceOrientationPortrait);
Jag gör det här för att hålla sakerna enkla så att vi kan fokusera på det största problemet vid handen.
Jag vill utveckla vår kod iterativt och förbättra det på ett inkrementellt sätt - som du verkligen skulle göra om du började från början - istället för att släppa den slutliga versionen på dig på en gång. Jag hoppas att detta tillvägagångssätt ger dig bättre hantering av de olika frågorna. Med tanke på detta och för att spara från att upprepade gånger radera, ändra och lägga till kod i samma fil, vilket kan bli rörigt och felaktigt, kommer jag att göra följande:
I Xcode, välj Fil> Ny> Fil ... , välj Mål-C-klass som mall och i nästa skärmnamn filen LinearInterpView och gör det till en underklass av UIView. Spara den. Namnet "LinearInterp" är kort för "linjär interpolation" här. För handledningens skull kommer jag att namnge varje UIView-underklass vi skapar för att betona något begrepp eller tillvägagångssätt infört i klasskoden.
Som jag tidigare nämnde kan du lämna huvudfilen som den är. Radera Allt koden som finns i LinearInterpView.m-filen och ersätt den med följande:
#import "LinearInterpView.h" @implementation LinearInterpView UIBezierPath * path; // (3) - (id) initWithCoder: (NSCoder *) aDecoder // (1) om (self = [super initWithCoder: aDecoder]) [self setMultipleTouchEnabled: NO]; // (2) [self setBackgroundColor: [UIColor whiteColor]]; sökväg = [UIBezierPath bezierPath]; [bana setLineWidth: 2.0]; återvänd själv - (void) drawRect: (CGRect) rect // (5) [[UIColor blackColor] setStroke]; [bana stroke]; - (void) touchesBegan: (NSSet *) berörs medEvent: (UIEvent *) händelse UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; [path moveToPoint: p]; - (void) touchesMoved: (NSSet *) berörs medEvent: (UIEvent *) händelse UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; [sökväg addLineToPoint: p]; // (4) [self setNeedsDisplay]; - (void) touchesEnded: (NSSet *) berörs medEvent: (UIEvent *) händelse [self touchesMoved: berörs medEvent: händelse]; - (void) touchesCancelled: (NSSet *) berörs medEvent: (UIEvent *) händelse [self touchesEnded: berörs medEvent: händelse]; @slutet
I den här koden arbetar vi direkt med de beröringshändelser som applikationen rapporterar till oss varje gång vi har en beräkningssekvens. det vill säga, användaren lägger ett finger på skärmen, flyttar fingret över det och lyfter slutligen fingret från skärmen. För varje händelse i denna sekvens skickar programmet oss ett motsvarande meddelande (i IOS-terminologi skickas meddelandena till "första responder", du kan referera till dokumentationen för detaljer).
För att hantera dessa meddelanden implementerar vi metoderna -touchesBegan: WithEvent:
och företag, som deklareras i UIResponder klassen från vilken UIView ärver. Vi kan skriva kod för att hantera beröringshändelserna, oavsett vad vi vill. I vår app vill vi fråga skärmens plats för beröringen, göra lite bearbetning och dra sedan linjer på skärmen.
Punkterna hänvisar till motsvarande kommenterade nummer från koden ovan:
-initWithCoder:
eftersom vyn är född från en XIB, som vi kommer att inrätta inom kort. UIBezierPath
är en UIKit-klass som låter oss rita former på skärmen bestående av raka linjer eller vissa kurvor. -drawRect:
metod. Vi gör detta genom att sträcka banan varje gång ett nytt linjesegment läggs till. -drawRect:
metod och resultatet av det du ser är visningen på skärmen. Vi kommer snart över en annan typ av teckningskontext.Innan vi kan bygga applikationen måste vi ange underklassen för visning som vi just skapat till skärmen.
Bygg nu programmet. Du ska få en blank vit vy som du kan dra in med ditt finger. Med tanke på de få rader av kod som vi har skrivit, är resultaten inte så lurbara! Naturligtvis är de inte heller spektakulära. Det är ganska märkbart att kontakten mellan punkterna syns (och ja, min handstil suger också).
Se till att du kör appen inte bara på simulatorn utan också på en riktig enhet.
Om du spelar med applikationen för en stund på din enhet, är du tvungen att lägga märke till någonting: så småningom börjar användargränssnittet att lagras och i stället för de ~ 60 kontaktpunkterna som förvärvades per sekund, av någon anledning, antalet poäng UI kan prova droppar längre och längre. Eftersom punkterna blir längre från varandra, gör raklinjens interpolering tecknet till och med "blockier" än tidigare. Detta är verkligen oönskat. Så vad händer?
Låt oss granska vad vi har gjort: när vi ritar, förvärvar vi poäng, lägger dem till en ständigt växande bana och sedan gör * fullständiga * -banan i varje krets i huvudslingan. Så som vägen blir längre, i varje iteration har ritningssystemet mer att rita och så småningom blir det för mycket vilket gör det svårt för appen att fortsätta. Eftersom allt händer på huvudtråden konkurrerar vår teckenkod med UI-koden som bland annat måste prova på handen på skärmen.
Du skulle bli förlåtad för att tänka att det var ett sätt att rita "ovanpå" vad som redan var på skärmen; Tyvärr är det här vi behöver bryta sig ur pennan-på-pappersanalog eftersom grafiksystemet inte fungerar som standard. Även om vi i kraft av den kod vi ska skriva nästa kommer vi indirekt att genomföra "draw-on-top" -metoden.
Medan det finns några saker vi kan försöka fixa vår kods prestanda, kommer vi bara att genomföra en idé, för det visar sig vara tillräckligt för våra nuvarande behov.
Skapa en ny UIView-underklass som du gjorde innan du namngav den CachedLIView (LI är att påminna oss om att vi fortfarande gör Li öra jagnterpolation). Ta bort hela innehållet i CachedLIView.m och ersätt det med följande:
#import "CachedLIView.h" @implementation CachedLIView UIBezierPath * path; Ullmage * incrementalImage; // (1) - (id) initWithCoder: (NSCoder *) aDecoder om (self = [super initWithCoder: aDecoder]) [self setMultipleTouchEnabled: NO]; [self setBackgroundColor: [UIColor whiteColor]]; sökväg = [UIBezierPath bezierPath]; [bana setLineWidth: 2.0]; återvänd själv - (void) drawRect: (CGRect) rect [incrementalImage drawInRect: rect]; // (3) [bana stroke]; - (void) touchesBegan: (NSSet *) berörs medEvent: (UIEvent *) händelse UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; [path moveToPoint: p]; - (void) touchesMoved: (NSSet *) berörs medEvent: (UIEvent *) händelse UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; [sökväg addLineToPoint: p]; [self setNeedsDisplay]; - (void) touchesEnded: (NSSet *) berörs medEvent: (UIEvent *) händelse // (2) UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; [sökväg addLineToPoint: p]; [self drawBitmap]; // (3) [self setNeedsDisplay]; [path removeAllPoints]; // (4) - (void) touchesCancelled: (NSSet *) berörs medEvent: (UIEvent *) händelse [self touchesEnded: berörs medEvent: händelse]; - (void) drawBitmap // (3) UIGraphicsBeginImageContextWithOptions (self.bounds.size, YES, 0.0); [[UIColor blackColor] setStroke]; om (! incrementalImage) // första teckning; måla bakgrund vit vid ... UIBezierPath * rectpath = [UIBezierPath bezierPathWithRect: self.bounds]; // enclosing bitmap med en rektangel definierad av ett annat UIBezierPath objekt [[UIColor whiteColor] setFill]; [rektangulär fyllning]; // fylla den med vit [incrementalImage drawAtPoint: CGPointZero]; [bana stroke]; incrementalImage = UIGraphicsGetImageFromCurrentImageContext (); UIGraphicsEndImageContext (); @slutet
Efter att ha sparat, kom ihåg att ändra klassen av visningsobjektet i din XIB (s) till CachedLIView!
När användaren lägger fingret på skärmen för att rita börjar vi med en ny väg utan punkter eller linjer i den, och vi lägger till linjesegment på det som vi gjorde tidigare.
Återigen, med hänvisning till siffrorna i kommentarerna:
-drawRect:
Detta sammanhang görs automatiskt tillgängligt för oss och återspeglar vad vi gör i vår skärmvy. I motsats härtill måste bitmap-kontexten skapas och förstöras explicit, och de tecknade innehållen finns i minnet.drawRect:
kallas vi först ritar innehållet i minnesbufferten i vår uppfattning, vilken (av design) har exakt samma storlek, och så för användaren behåller vi illusionen av kontinuerlig ritning, bara på ett annat sätt än tidigare.Även om detta inte är perfekt (vad händer om vår användare fortsätter att rita utan att lyfta fingret någonsin?), Kommer det att vara tillräckligt bra för omfattningen av denna handledning. Du uppmanas att självständigt experimentera för att hitta en bättre metod. Du kan till exempel försöka cache ritningen periodiskt istället för endast när användaren lyfter fingret. Som det händer, ger detta off-screen caching-procedur oss möjlighet till bakgrundsbehandling, om vi väljer att implementera det. Men vi ska inte göra det i den här handledningen. Du är inbjuden att försöka på egen hand, dock!
Låt oss nu uppmärksamma att teckningen "ser bättre ut". Hittills har vi ansluter intilliggande touchpunkter med raka linjesegment. Men normalt när vi ritar frihand har vår naturliga stroke ett fritt flytande och kurvigt (i stället för blockerat och stelt) utseende. Det är vettigt att vi försöker interpolera våra punkter med kurvor snarare än linjesegment. Lyckligtvis låter UIBezierPath-klassen oss dra sitt namne: Bezier-kurvor.
Vad är Bezier kurvor? Utan att åberopa den matematiska definitionen definieras en Bezier-kurva av fyra punkter: två ändpunkter genom vilka en kurva passerar och två "kontrollpunkter" som hjälper till att definiera tangenter som kurvan måste röra vid sina ändpunkter (det här är tekniskt en kubisk Bezier-kurva, men för enkelhet hänvisar jag till det som en "Bezier-kurva").
Bezierkurvor gör att vi kan dra alla slags intressanta former.
Vad vi ska försöka nu är att gruppera sekvenser av fyra intilliggande kontaktpunkter och interpolera punktsekvensen i ett Bezier-kurvsegment. Varje angränsande par Bezier-segment kommer att dela en slutpunkt gemensamt för att upprätthålla strokeens kontinuitet.
Du känner till borren nu. Skapa ett nytt UIView-underklass och namnge det BezierInterpView. Klistra in följande kod i .m-filen:
#import "BezierInterpView.h" @implementation BezierInterpView UIBezierPath * path; Ullmage * incrementalImage; CGPoint-poäng [4]; // för att hålla reda på de fyra punkterna i vårt Bezier-segment uint ctr; // en räknare för att hålla reda på punktindexet - (id) initWithCoder: (NSCoder *) aDecoder if (self = [super initWithCoder: aDecoder]) [self setMultipleTouchEnabled: NO]; [self setBackgroundColor: [UIColor whiteColor]]; sökväg = [UIBezierPath bezierPath]; [bana setLineWidth: 2.0]; återvänd själv - (void) drawRect: (CGRect) rect [incrementalImage drawInRect: rect]; [bana stroke]; - (void) touchesBegan: (NSSet *) berörs medEvent: (UIEvent *) händelse ctr = 0; UITouch * touch = [berör anyObject]; pts [0] = [touch locationInView: self]; - (void) touchesMoved: (NSSet *) berörs medEvent: (UIEvent *) händelse UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; ctr ++; pts [ctr] = p; om (ctr == 3) // 4: e punkt [path moveToPoint: pts [0]]; [sökväg addCurveToPoint: pts [3] controlPoint1: pts [1] controlPoint2: pts [2]]; // Det här är hur en Bezierkurva läggs till på en bana. Vi lägger till en kubisk Bezier från pt [0] till pt [3], med kontrollpunkter pt [1] och pt [2] [self setNeedsDisplay]; pts [0] = [path currentPoint]; ctr = 0; - (void) touchesEnded: (NSSet *) berörs medEvent: (UIEvent *) händelse [self drawBitmap]; [self setNeedsDisplay]; pts [0] = [path currentPoint]; // låt den andra ändpunkten för det nuvarande Bezier-segmentet vara det första för nästa Bezier-segment [path removeAllPoints]; ctr = 0; - (void) touchesCancelled: (NSSet *) berörs medEvent: (UIEvent *) händelse [self touchesEnded: berörs medEvent: händelse]; - (void) drawBitmap UIGraphicsBeginImageContextWithOptions (self.bounds.size, YES, 0.0); [[UIColor blackColor] setStroke]; om (! incrementalImage) // första gången; måla bakgrund vit UIBezierPath * rectpath = [UIBezierPath bezierPathWithRect: self.bounds]; [[UIColor whiteColor] setFill]; [rektangulär fyllning]; [incrementalImage drawAtPoint: CGPointZero]; [bana stroke]; incrementalImage = UIGraphicsGetImageFromCurrentImageContext (); UIGraphicsEndImageContext (); @slutet
Som inline-kommentarerna indikerar är huvudändringen införandet av ett par nya variabler för att hålla koll på punkterna i våra Bezier-segment och en modifiering av -(Void) touchesMoved: withEvent:
metod för att rita ett Bezier-segment för varje fyra punkter (faktiskt varje tre punkter, när det gäller de rör som appen rapporterar till oss, eftersom vi delar ett slutpunkt för varje par i närliggande Bezier-segment).
Du kan påpeka att vi har försummat fallet om att användaren lyfter fingret och avslutar beröringssekvensen innan vi har tillräckligt med poäng för att slutföra vårt senaste Bezier-segment. Om så är fallet skulle du ha rätt! Medan visuellt gör det inte mycket skillnad, i vissa viktiga fall gör det. Försök till exempel att dra en liten cirkel. Det kanske inte stängs helt, och i en riktig app skulle du vilja hantera detta på rätt sätt -touchesEnded: WithEvent
metod. Samtidigt som vi har det, har vi inte heller givit några speciella hänsyn till fallet med beröringsavbrott. De touchesCancelled: WithEvent
instansmetoden hanterar detta. Ta en titt på den officiella dokumentationen och se om det finns några speciella fall som du kanske behöver hantera här.
Så, hur ser resultaten ut? Återigen påminner jag dig om att ställa in rätt klass i XIB före byggandet.
Va. Det verkar inte som en hel del förbättring, gör det? Jag tror det kan vara lite bättre än rak linje interpolation, eller kanske det är bara önsketänkande. I alla fall är det inget värt att skryta om.
Här är vad jag tycker händer: medan vi tar problem att interpolera varje sekvens av fyra punkter med ett jämnt kurvsegment, Vi gör ingen ansträngning för att göra ett kurvsegment för att övergå smidigt till nästa, Så effektivt har vi fortfarande ett problem med slutresultatet.
Så vad kan vi göra åt det? Om vi ska hålla fast vid det tillvägagångssätt som vi började i den senaste versionen (det vill säga med Bezier-kurvor), måste vi ta hand om kontinuiteten och jämnheten vid "korsningspunkten" i två närliggande Bezier-segment. De två tangenterna vid slutpunkten med motsvarande kontrollpunkter (andra kontrollpunkten för det första segmentet och första kontrollpunkten i det andra segmentet) verkar vara nyckeln; om båda dessa tangenter hade samma riktning, skulle kurvan vara jämnare vid korsningen.
Vad händer om vi flyttar den gemensamma slutpunkten någonstans på linjen som går med i de två kontrollpunkterna? Utan att använda ytterligare data om beröringspunkterna verkar den bästa punkten vara mittpunkten för linjen som sammanfogar de två kontrollpunkterna med hänsyn till, och vårt pålagda krav på riktningen av de två tangenterna skulle vara nöjda. Låt oss försöka detta!
Skapa ett UIView-underklass (ännu en gång) och namnge det SmoothedBIView. Byt hela koden i .m-filen med följande:
#import "SmoothedBIView.h" @implementation SmoothedBIView UIBezierPath * path; Ullmage * incrementalImage; CGPoint-poäng [5]; // vi behöver nu hålla reda på de fyra punkterna i ett Bezier-segment och den första kontrollpunkten i nästa segment uint ctr; - (id) initWithCoder: (NSCoder *) aDecoder om (self = [super initWithCoder: aDecoder]) [self setMultipleTouchEnabled: NO]; [self setBackgroundColor: [UIColor whiteColor]]; sökväg = [UIBezierPath bezierPath]; [bana setLineWidth: 2.0]; återvänd själv - (id) initWithFrame: (CGRect) ram self = [super initWithFrame: frame]; om (själv) [self setMultipleTouchEnabled: NO]; sökväg = [UIBezierPath bezierPath]; [bana setLineWidth: 2.0]; återvänd själv // Överrätta endast drawRect: om du utför anpassad ritning. // Ett tomt genomförande påverkar prestanda under animering negativt. - (void) drawRect: (CGRect) rect [incrementalImage drawInRect: rect]; [bana stroke]; - (void) touchesBegan: (NSSet *) berörs medEvent: (UIEvent *) händelse ctr = 0; UITouch * touch = [berör anyObject]; pts [0] = [touch locationInView: self]; - (void) touchesMoved: (NSSet *) berörs medEvent: (UIEvent *) händelse UITouch * touch = [berör anyObject]; CGPoint p = [touch locationInView: self]; ctr ++; pts [ctr] = p; om (ctr == 4) pts [3] = CGPointMake ((pts [2] .x + pts [4] .x) /2.0, (pts [2] .y + pts [4] .y) /2.0 ); // flytta slutpunkten till mitten av linjen som sammanfogar den andra kontrollpunkten i det första Bezier-segmentet och den första kontrollpunkten för det andra Bezier-segmentet [path moveToPoint: pts [0]]; [sökväg addCurveToPoint: pts [3] controlPoint1: pts [1] controlPoint2: pts [2]]; // Lägg till en kubisk Bezier från pt [0] till pt [3], med kontrollpunkter pt [1] och pt [2] [self setNeedsDisplay]; // ersätt punkter och gör dig redo att hantera nästa segment pts [0] = pts [3]; pts [1] = pts [4]; ctr = 1; - (void) touchesEnded: (NSSet *) berörs medEvent: (UIEvent *) händelse [self drawBitmap]; [self setNeedsDisplay]; [path removeAllPoints]; ctr = 0; - (void) touchesCancelled: (NSSet *) berörs medEvent: (UIEvent *) händelse [self touchesEnded: berörs medEvent: händelse]; - (void) drawBitmap UIGraphicsBeginImageContextWithOptions (self.bounds.size, YES, 0.0); om (! incrementalImage) // första gången; måla bakgrund vit UIBezierPath * rectpath = [UIBezierPath bezierPathWithRect: self.bounds]; [[UIColor whiteColor] setFill]; [rektangulär fyllning]; [incrementalImage drawAtPoint: CGPointZero]; [[UIColor blackColor] setStroke]; [bana stroke]; incrementalImage = UIGraphicsGetImageFromCurrentImageContext (); UIGraphicsEndImageContext (); @slutet
Kärnan i den algoritm vi diskuterade ovan är implementerad i -touchesMoved: WithEvent: metod. Inline-kommentarerna bör hjälpa dig att länka diskussionen med koden.
Så hur är resultaten, visuellt? Kom ihåg att göra saken med XIB.
Lyckligtvis finns det betydande förbättringar den här gången. Med tanke på enkelheten i vår modifiering ser det ganska bra ut (om jag själv säger det!). Vår analys av problemet med tidigare iteration och vår föreslagna lösning har också validerats.
Jag hoppas att du hittade denna handledning till nytta. Förhoppningsvis utvecklar du dina egna idéer om hur du förbättrar koden. En av de viktigaste (men enkla) förbättringar som du kan införliva hanterar slutet av pekningssekvenserna mer graciöst, som diskuterats tidigare.
Ett annat fall som jag försummade handlar om att hantera en touch-sekvens som består av användaren som rör utsikten med fingret och sedan lyfter det utan att ha flyttat det - effektivt en kran på skärmen. Användaren skulle förmodligen förvänta sig att dra en punkt eller liten squiggle på utsikten så här, men med vår nuvarande implementering händer ingenting eftersom vår ritningskod inte sparkar in om inte vår syn mottar -touchesMoved: WithEvent: meddelande. Du kanske vill ta en titt på UIBezierPath
klass dokumentation för att se vilka andra typer av vägar du kan konstruera.
Om din app gör mer arbete än vad vi gjorde här (och i en ritningsbar app som är värd att skicka, skulle det!), Utforma det så att icke-användarkoden (i synnerhet skärmen för skärmbilden) körs i en bakgrundsgänga kanske göra stor skillnad på en multicore-enhet (iPad 2 och framåt). Även på en enprocessorns enhet, som iPhone 4, bör prestanda förbättras, eftersom jag förväntar mig att processorn skulle dela upp cacheringsarbetet, som trots allt händer bara en gång varje fåtal cykler i huvudslingan.
Jag uppmanar dig att böja dina kodande muskler och spela med UIKit API för att utveckla och förbättra några av de idéer som implementerats i denna handledning. Ha det roligt och tack för att du läste!