Denne opplæringen vil lære deg hvordan du implementerer en avansert tegningsalgoritme for jevn, frihånds tegning på iOS-enheter. Les videre!
Touch er den primære måten en bruker vil samhandle med iOS-enheter. En av de mest naturlige og åpenbare funksjonalitetene disse enhetene forventes å gi, er at brukeren kan tegne på skjermen med fingeren. Det er mange frihånds tegne- og notatprogrammer for tiden i App Store, og mange selskaper spør selv kunder om å signere en iDevice når de foretar kjøp. Hvordan fungerer disse programmene egentlig? La oss stoppe og tenke litt om hva som skjer "under hetten".
Når en bruker ruller en tabellvisning, klemmer seg for å forstørre et bilde, eller tegner en kurve i en malingsapp, vises enhetsvisningen raskt (si 60 ganger i sekundet) og programløpsløpet er konstant sampling plasseringen av brukerens finger (e). Under denne prosessen må "analog" inngang på en finger som trekker over skjermen konverteres til et digitalt sett med poeng på displayet, og denne konverteringsprosessen kan utgjøre betydelige utfordringer. I sammenheng med vår malingsapp har vi et "data-passende" problem på våre hender. Siden brukeren skriker vekk på enheten, må programmereren i det hele tatt interpolere manglende analog informasjon ("connect-the-dots") som har gått tapt blant de samplede berøringspunktene som iOS rapporterte til oss. Videre må denne interpolasjonen oppstå slik at resultatet er et slag som ser kontinuerlig, naturlig og glatt ut til sluttbrukeren, som om han hadde skissert med en penn på et notisblokk laget av papir.
Formålet med denne opplæringen er å vise hvordan frihåndstegning kan implementeres på iOS, med utgangspunkt i en grunnleggende algoritme som utfører rettlinjespolering og fremmer en mer sofistikert algoritme som nærmer seg kvaliteten som tilbys av kjente applikasjoner som Penultimate. Som om å lage en algoritme som virker, er ikke vanskelig nok, må vi også sørge for at algoritmen fungerer bra. Som vi skal se, kan en naiv tegning implementering føre til en app med betydelige ytelsesproblemer som vil gjøre tegning tungvint og til slutt ubrukelig.
Jeg antar at du ikke er helt ny i IOS-utvikling, så jeg har skummet over trinnene for å skape et nytt prosjekt, legge til filer i prosjektet, etc. Forhåpentligvis er det ikke noe for vanskelig her uansett, men bare hvis Full prosjektkode er tilgjengelig for deg å laste ned og leke med.
Start et nytt Xcode iPad-prosjekt basert på "Enkeltvisningsprogram"Maler og navn det"FreehandDrawingTut". Vær sikker på å aktivere automatisk referansetelling (ARC), men å avvelge Storyboards og enhetstester. Du kan gjøre dette prosjektet enten en iPhone eller Universal app, avhengig av hvilke enheter du har tilgjengelig for testing.
Deretter fortsetter du og velger "FreeHandDrawingTut" -prosjektet i Xcode Navigator og sørg for at bare portrettretningen støttes:
Hvis du skal distribuere til iOS 5.x eller tidligere, kan du endre orienteringsstøtten på denne måten:
- (BOOL) shouldAutorotateToInterfaceOrientation: (UIInterfaceOrientation) interfaceOrientation return (interfaceOrientation == UIInterfaceOrientationPortrait);
Jeg gjør dette for å holde ting enkelt, så vi kan fokusere på hovedproblemet ved hånden.
Jeg ønsker å utvikle koden iterativt og forbedre det på en inkrementell måte - som du ville realistisk gjøre hvis du startet fra bunnen av - i stedet for å slippe den endelige versjonen på deg på en gang. Jeg håper denne tilnærmingen vil gi deg et bedre håndtak på de forskjellige problemene som er involvert. Med dette i bakhodet, og for å lagre fra å måtte gjentatte ganger slette, endre og legge til kode i samme fil, som kan bli rotete og feilproblemer, vil jeg ta følgende tilnærming:
I Xcode velger du Fil> Ny> Fil ... , velg Objective-C klasse som mal, og på neste skjermnavn filen LinearInterpView og gjør det til en underklasse av UIView. Lagre det. Navnet "LinearInterp" er kort for "lineær interpolering" her. For veiledningens skyld skal jeg nevne alle UIView-underklasser vi lager for å understreke noe konsept eller tilnærming introdusert i klassekoden.
Som jeg tidligere nevnte, kan du legge headerfilen som den er. Slett alle koden til stede i LinearInterpView.m-filen, og erstatt den med følgende:
#import "LinearInterpView.h" @implementation LinearInterpView UIBezierPath * path; // (3) - (id) initWithCoder: (NSCoder *) aDecoder // (1) hvis (selv = [super initWithCoder: aDecoder]) [self setMultipleTouchEnabled: NO]; // (2) [selv sattBackgroundColor: [UIColor whiteColor]]; sti = [UIBezierPath bezierPath]; [sti setLineWidth: 2.0]; returner selv; - (void) drawRect: (CGRect) rect // (5) [[UIColor blackColor] setStroke]; [banestrek] - (void) berørerBegan: (NSSet *) berører withEvent: (UIEvent *) hendelse UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; [sti moveToPoint: p]; - (void) touchesMoved: (NSSet *) berører withEvent: (UIEvent *) hendelse UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; [bane addLineToPoint: p]; // (4) [self setNeedsDisplay]; - (void) touchesEnded: (NSSet *) berører withEvent: (UIEvent *) hendelse [self touchesMoved: berører withEvent: event]; - (void) berørerCancelled: (NSSet *) berører withEvent: (UIEvent *) hendelse [self touchesEnded: berører withEvent: event]; @slutt
I denne koden jobber vi direkte med berøringshendelsene som programmet rapporterer til oss hver gang vi har en berøringssekvens. det vil si at brukeren plasserer en finger på skjermvisningen, beveger fingeren over den, og til slutt løfter fingeren fra skjermen. For hver hendelse i denne sekvensen sender søknaden oss en tilsvarende melding (i iOS-terminologi sendes meldingene til "første responder", du kan se dokumentasjonen for detaljer).
For å håndtere disse meldingene implementerer vi metodene -touchesBegan: WithEvent:
og selskap, som er erklært i UIResponder-klassen som UIView arver fra. Vi kan skrive kode for å håndtere berøringshendelser uansett hva vi liker. I vår app ønsker vi å spørre om plasseringen av berøringene på skjermen, gjør noe behandling, og deretter tegne linjer på skjermen.
Poengene refererer til de tilsvarende kommenterte tallene fra koden ovenfor:
-initWithCoder:
fordi visningen er født fra en XIB, som vi vil sette opp kort tid. UIBezierPath
er en UIKit klasse som lar oss tegne figurer på skjermen som består av rette linjer eller visse typer kurver. -drawRect:
metode. Vi gjør dette ved å strekke banen hver gang et nytt linjesegment er lagt til. -drawRect:
metode, og resultatet av det du ser er visningen på skjermen. Vi kommer snart over en annen tegningskontekst.Før vi kan bygge programmet, må vi sette visningsunderklassen vi nettopp har opprettet til skjermvisningen.
Bygg nå applikasjonen. Du bør få en skinnende hvit visning som du kan trekke inn med fingeren. Med tanke på de få kodelinjene vi har skrevet, er resultatene ikke så loslitt! Selvfølgelig er de heller ikke spektakulære. Koblingen av prikkene er ganske merkbar (og ja, min håndskrift suger også).
Pass på at du kjører appen ikke bare på simulatoren, men også på en ekte enhet.
Hvis du spiller med applikasjonen for en stund på enheten din, er du nødt til å legge merke til noe: Til slutt begynner brukergrensesnittet å ligge, og i stedet for de ~ 60 berøringspunktene som ble kjøpt per sekund, av en eller annen grunn antall poeng UI er i stand til å prøve dråper lenger og lengre. Siden punktene blir lenger fra hverandre, gjør straight-line interpoleringen tegningen enda "blokkere" enn før. Dette er absolutt uønsket. Så hva skjer?
La oss se på hva vi har gjort: Når vi tegner, skaffer vi poeng, legger dem til en stadig voksende bane, og gjør deretter * fullstendig * banen i hver syklus av hovedløkken. Så som banen blir lengre, i hver iterasjon har tegningssystemet mer å tegne og til slutt blir det for mye, noe som gjør det vanskelig for appen å holde tritt. Siden alt skjer på hovedtråden, konkurrerer tegningskoden vår med UI-koden som blant annet må smake på berøringen på skjermen.
Du ville bli tilgitt for å tenke det var en måte å tegne "på toppen av" det som allerede var på skjermen; Dessverre er dette her hvor vi må bryte fri av penn-på-papir-analogien, da grafikksystemet ikke fungerer som standard. Selv om vi i kraft av koden skal skrive neste, vil vi indirekte gjennomføre "draw-on-top" -tilnærming.
Selv om det er noen ting vi kan forsøke å fikse utførelsen av koden vår, skal vi bare implementere en ide, fordi det viser seg å være tilstrekkelig for våre nåværende behov.
Opprett en ny UIView-underklasse som du gjorde før, navngi den CachedLIView (LI er å minne oss om at vi fortsatt gjør Li øret Jegnterpolation). Slett alt innholdet i CachedLIView.m og erstatt den med følgende:
#import "CachedLIView.h" @implementation CachedLIView UIBezierPath * path; UIImage * incrementalImage; // (1) - (id) initWithCoder: (NSCoder *) aDecoder hvis (selv = [super initWithCoder: aDecoder]) [selvsettetMultipleTouchEnabled: NO]; [self setBackgroundColor: [UIColor whiteColor]]; sti = [UIBezierPath bezierPath]; [sti setLineWidth: 2.0]; returner selv; - (void) drawRect: (CGRect) rect [incrementalImage drawInRect: rect]; // (3) [banestrek]; - (void) berørerBegan: (NSSet *) berører withEvent: (UIEvent *) hendelse UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; [sti moveToPoint: p]; - (void) touchesMoved: (NSSet *) berører withEvent: (UIEvent *) hendelse UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; [bane addLineToPoint: p]; [self setNeedsDisplay]; - (void) touchEnded: (NSSet *) berører withEvent: (UIEvent *) hendelse // (2) UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; [bane addLineToPoint: p]; [self drawBitmap]; // (3) [self setNeedsDisplay]; [sti removeAllPoints]; // (4) - (void) berørerCancelled: (NSSet *) berører withEvent: (UIEvent *) hendelse [self touchesEnded: berører withEvent: event]; - (void) drawBitmap // (3) UIGraphicsBeginImageContextWithOptions (self.bounds.size, YES, 0.0); [[UIColor blackColor] setStroke]; hvis (! incrementalImage) // første tegning; male bakgrunnen hvit av ... UIBezierPath * rectpath = [UIBezierPath bezierPathWithRect: self.bounds]; // omslutte bitmappe med et rektangel definert av en annen UIBezierPath-objekt [[UIColor whiteColor] setFill]; [rektangulær fylling]; // fylle det med hvitt [incrementalImage drawAtPoint: CGPointZero]; [banestrek] incrementalImage = UIGraphicsGetImageFromCurrentImageContext (); UIGraphicsEndImageContext (); @slutt
Når du har lagret, må du huske å endre klassen av visningsobjektet i XIB (er) til CachedLIView!
Når brukeren plasserer fingeren på skjermen for å tegne, starter vi med en ny bane uten punkter eller linjer i den, og vi legger til linjesegmenter på det som vi gjorde før.
Igjen, med henvisning til tallene i kommentarene:
-drawRect:
Denne konteksten blir automatisk gjort tilgjengelig for oss og gjenspeiler det vi trekker inn i vår skjermvisning. I kontrast må bitmap-konteksten bli opprettet og ødelagt eksplisitt, og de trekkede innholdene ligger i minnet.drawRect:
kalles, trekker vi først innholdet i minnesbufferen i vårt syn, som (av design) har nøyaktig samme størrelse, og så for brukeren opprettholder vi illusjonen av kontinuerlig tegning, bare på en annen måte enn før.Selv om dette ikke er perfekt (hva hvis vår bruker holder tegning uten å heve fingeren sin, noensinne?), Vil det være godt nok for omfanget av denne opplæringen. Du oppfordres til å eksperimentere på egenhånd for å finne en bedre metode. For eksempel kan du prøve å cache tegningen periodisk i stedet for bare når brukeren løfter fingeren. Når det skjer, gir denne avskjermingsprosedyren oss muligheten til bakgrunnsbehandling, hvis vi velger å implementere det. Men vi skal ikke gjøre det i denne opplæringen. Du er imidlertid invitert til å prøve på egen hånd!
La oss nå legge merke til at tegningen "ser bedre ut". Så langt har vi gått med tilstøtende berøringspunkter med rette linjesegmenter. Men normalt når vi tegner frihånd, har vårt naturlige slag et frittflytende og svingete (i stedet for blokkert og stivt) utseende. Det er fornuftig at vi prøver å interpolere våre poeng med kurver i stedet for linjesegmenter. Heldigvis lar UIBezierPath-klassen å trekke sin navnebror: Bezier-kurver.
Hva er Bezier-kurver? Uten å påkalle den matematiske definisjonen, er en Bezier-kurve definert av fire punkter: to endepunkter som en kurve passerer og to "kontrollpunkter" som hjelper til å definere tangenter som kurven må røre ved endepunktene (dette er teknisk sett en kubisk Bezier-kurve, men for enkelhet vil jeg referere til det som bare en "Bezier-kurve").
Bezier-kurver gir oss mulighet til å tegne alle slags interessante former.
Det vi skal prøve nå, er å gruppere sekvenser av fire tilstøtende berøringspunkter og interpolere punktsekvensen i et Bezier-kurvesegment. Hvert tilstøtende par Bezier-segmenter vil dele et sluttpunkt til felles for å opprettholde strekkets kontinuitet.
Du kjenner boret nå. Opprett en ny UIView-underklasse og navnet på den BezierInterpView. Lim inn følgende kode i .m-filen:
#import "BezierInterpView.h" @implementation BezierInterpView UIBezierPath * path; UIImage * incrementalImage; CGPoint pts [4]; // for å holde oversikt over de fire punktene i Bezier-segmentet uint ctr; // en tellervariabel for å holde oversikt over punktindeksen - (id) initWithCoder: (NSCoder *) aDecoder hvis (selv = [super initWithCoder: aDecoder]) [selvsettetMultipleTouchEnabled: NO]; [self setBackgroundColor: [UIColor whiteColor]]; sti = [UIBezierPath bezierPath]; [sti setLineWidth: 2.0]; returner selv; - (void) drawRect: (CGRect) rect [incrementalImage drawInRect: rect]; [banestrek] - (void) berørerBegan: (NSSet *) berører withEvent: (UIEvent *) hendelse ctr = 0; UITouch * touch = [berører anyObject]; pts [0] = [touch locationInView: self]; - (void) touchesMoved: (NSSet *) berører withEvent: (UIEvent *) hendelse UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; ctr ++; pts [ctr] = p; hvis (ctr == 3) // 4 poeng [path moveToPoint: pts [0]]; [bane addCurveToPoint: pts [3] controlPoint1: pts [1] controlPoint2: pts [2]]; // Dette er hvordan en Bezier-kurve legges til en sti. Vi legger til en kubisk Bezier fra pt [0] til pt [3], med kontrollpunkter pt [1] og pt [2] [self setNeedsDisplay]; pts [0] = [path currentPoint]; ctr = 0; - (void) touchesEnded: (NSSet *) berører withEvent: (UIEvent *) event [self drawBitmap]; [self setNeedsDisplay]; pts [0] = [path currentPoint]; // la det andre endepunktet for det nåværende Bezier-segmentet være det første for neste Bezier-segment [path deleteAllPoints]; ctr = 0; - (void) berørerCancelled: (NSSet *) berører withEvent: (UIEvent *) hendelse [self touchesEnded: berører withEvent: event]; - (void) drawBitmap UIGraphicsBeginImageContextWithOptions (self.bounds.size, YES, 0.0); [[UIColor blackColor] setStroke]; hvis (! incrementalImage) // første gang; maling bakgrunn hvit UIBezierPath * rectpath = [UIBezierPath bezierPathWithRect: self.bounds]; [[UIColor whiteColor] setFill]; [rektangulær fylling]; [incrementalImage drawAtPoint: CGPointZero]; [banestrek] incrementalImage = UIGraphicsGetImageFromCurrentImageContext (); UIGraphicsEndImageContext (); @slutt
Som inline-kommentarene indikerer, er hovedendringen innføringen av et par nye variabler for å holde oversikt over punktene i våre Bezier-segmenter, og en modifisering av -(Void) touchesMoved: withEvent:
Metode for å tegne et Bezier-segment for hver fjerde poeng (faktisk hvert tre poeng, i forhold til de berøringene som appen rapporterer til oss, fordi vi deler ett sluttpunkt for hvert par tilstøtende Bezier-segmenter).
Du kan påpeke at vi har forsømt tilfellet om at brukeren løfter fingeren og slutter berøringssekvensen før vi har nok poeng til å fullføre vårt siste Bezier-segment. I så fall ville du ha det riktig! Selv om det visuelt ikke er mye forskjell, er det i visse viktige tilfeller det. For eksempel, prøv å tegne en liten sirkel. Det kan ikke lukke helt, og i en ekte app vil du ha hensiktsmessig å håndtere dette i -touchesEnded: WithEvent
metode. Mens vi er i det, har vi heller ikke gitt noen spesiell hensyn til tilfelle av berøringsavbestilling. De touchesCancelled: WithEvent
eksempelmetoden håndterer dette. Ta en titt på den offisielle dokumentasjonen og se om det er noen spesielle tilfeller som du kanskje trenger å håndtere her.
Så, hvordan ser resultatene ut? Igjen, påminner jeg deg om å sette riktig klasse i XIB før du bygger.
Hu h. Det virker ikke som en hel del forbedring, gjør det? Jeg tror det kan være litt bedre enn rett linje interpolering, eller kanskje det bare er ønskelig tenkning. I alle fall er det ingenting verdt å skryte om.
Her er det jeg tror skjer: mens vi tar det vanskelig å interpolere hver sekvens på fire punkter med et glatt kurvesegment, Vi gjør ingen innsats for å lage et kurvesegment for å overføre jevnt til det neste, Så effektivt har vi fortsatt et problem med sluttresultatet.
Så hva kan vi gjøre med det? Hvis vi skal holde fast i tilnærmingen vi startet i den siste versjonen (det vil si ved bruk av Bezier-kurver), må vi ta vare på kontinuiteten og glattheten ved "veikrysspunktet" i to tilstøtende Bezier-segmenter. De to tangenter på sluttpunktet med de tilsvarende kontrollpunktene (det andre kontrollpunktet i det første segmentet og det første kontrollpunktet i det andre segmentet) synes å være nøkkelen; Hvis begge disse tangentene hadde samme retning, ville kurven være jevnere ved krysset.
Hva om vi flyttet det felles endepunktet et eller annet sted på linjen som kommer til de to kontrollpunktene? Uten å benytte ytterligere data om berøringspunktene, synes det beste punktet å være midtpunktet på linjen som kommer til de to kontrollpunktene i betraktning, og vårt pålagte krav til retningen til de to tangenter vil være tilfredsstilt. La oss prøve dette!
Opprett en UIView-underklasse (enda en gang) og navnet den SmoothedBIView. Erstatt hele koden i .m-filen med følgende:
#import "SmoothedBIView.h" @implementation SmoothedBIView UIBezierPath * path; UIImage * incrementalImage; CGPoint pts [5]; // vi trenger nå å holde rede på de fire punktene i et Bezier-segment og det første kontrollpunktet i neste segment uint ctr; - (id) initWithCoder: (NSCoder *) aDecoder hvis (selv = [super initWithCoder: aDecoder]) [selvsettetMultipleTouchEnabled: NO]; [self setBackgroundColor: [UIColor whiteColor]]; sti = [UIBezierPath bezierPath]; [sti setLineWidth: 2.0]; returner selv; - (id) initWithFrame: (CGRect) ramme self = [super initWithFrame: frame]; hvis (selv) [selvsettetMultipleTouchEnabled: NO]; sti = [UIBezierPath bezierPath]; [sti setLineWidth: 2.0]; returner selv; // Bare overstyr drawRect: hvis du utfører egendefinert tegning. // En tom implementering påvirker ytelsen under animasjon negativt. - (void) drawRect: (CGRect) rect [incrementalImage drawInRect: rect]; [banestrek] - (void) berørerBegan: (NSSet *) berører withEvent: (UIEvent *) hendelse ctr = 0; UITouch * touch = [berører anyObject]; pts [0] = [touch locationInView: self]; - (void) touchesMoved: (NSSet *) berører withEvent: (UIEvent *) hendelse UITouch * touch = [berører anyObject]; CGPoint p = [touch locationInView: self]; ctr ++; pts [ctr] = p; hvis (ctr == 4) pts [3] = CGPointMake ((pts [2] .x + pts [4] .x) /2.0, (pts [2] .y + pts [4] .y) /2.0 ); // flytte endepunktet til midten av linjen som kommer til det andre kontrollpunktet i det første Bezier-segmentet og det første kontrollpunktet til det andre Bezier-segmentet [path moveToPoint: pts [0]]; [bane addCurveToPoint: pts [3] controlPoint1: pts [1] controlPoint2: pts [2]]; // legg til en kubisk Bezier fra pt [0] til pt [3], med kontrollpunkter pt [1] og pt [2] [self setNeedsDisplay]; // erstatte punkter og gjør deg klar til å håndtere neste segment pts [0] = pts [3]; pts [1] = pts [4]; ctr = 1; - (void) touchesEnded: (NSSet *) berører withEvent: (UIEvent *) event [self drawBitmap]; [self setNeedsDisplay]; [sti removeAllPoints]; ctr = 0; - (void) berørerCancelled: (NSSet *) berører withEvent: (UIEvent *) hendelse [self touchesEnded: berører withEvent: event]; - (void) drawBitmap UIGraphicsBeginImageContextWithOptions (self.bounds.size, YES, 0.0); hvis (! incrementalImage) // første gang; maling bakgrunn hvit UIBezierPath * rectpath = [UIBezierPath bezierPathWithRect: self.bounds]; [[UIColor whiteColor] setFill]; [rektangulær fylling]; [incrementalImage drawAtPoint: CGPointZero]; [[UIColor blackColor] setStroke]; [banestrek] incrementalImage = UIGraphicsGetImageFromCurrentImageContext (); UIGraphicsEndImageContext (); @slutt
Kjernen i algoritmen vi diskuterte ovenfor er implementert i -touchesMoved: WithEvent: metode. Inline-kommentarene skal hjelpe deg å knytte diskusjonen med koden.
Så, hvordan er resultatet, visuelt sett? Husk å gjøre saken med XIB.
Heldigvis er det betydelig forbedring denne gangen. Tatt i betraktning enkelheten i vår modifikasjon, det ser ganske bra ut (hvis jeg sier det selv!). Vår analyse av problemet med den tidligere iterasjonen, og vår foreslåtte løsning, er også validert.
Jeg håper du fant denne opplæringen gunstig. Forhåpentligvis utvikler du dine egne ideer om hvordan du forbedrer koden. En av de viktigste (men enkle) forbedringene du kan innlemme, er å håndtere slutten av berøringssekvensene mer grasiøst, som diskutert tidligere.
Et annet tilfelle jeg forsømte, er å håndtere en berøringssekvens som består av brukeren som berører visningen med fingeren og løfter den uten å ha flyttet den - effektivt et trykk på skjermen. Brukeren vil trolig forvente å tegne et punkt eller et lite snigle på visningen på denne måten, men med vår nåværende implementering skjer ingenting fordi vår tegningskode ikke sparker inn med mindre vår visning mottar -touchesMoved: WithEvent: budskap. Du vil kanskje se på UIBezierPath
klassen dokumentasjon for å se hvilke andre typer stier du kan konstruere.
Hvis appen din gjør mer arbeid enn det vi gjorde her (og i en tegnefil verdt å sende, ville det!), Utforme det slik at ikke-UI-koden (spesielt skjermbildet på skjermen) kjører i en bakgrunnstråd, kanskje gjøre stor forskjell på en multicore-enhet (iPad 2 og fremover). Selv på en enkeltprosessor-enhet, som for eksempel iPhone 4, bør ytelsen forbedres, siden jeg forventer at prosessoren vil dele opp cache-arbeidet som etter hvert bare skjer en gang i noen få sykluser av hovedsløyfen.
Jeg oppfordrer deg til å bøye kodingsmusklene dine og spille med UIKit API for å utvikle og forbedre noen av de ideene som er implementert i denne opplæringen. Ha det gøy og takk for å lese!