MaCocoa 039

Capitolo 039 - Primi esperimenti con le viste

Comincio a fare alcuni esperimenti con le viste, ed in particolare con una vista proprietaria per progettare copertine di CD.

Leggo la documentazione...

Primo inserimento: 9 gennaio 2004

Una vista particolare

Se si deve visualizzare qualcosa che non appartiene all'ampio catalogo delle viste predefinite di Cocoa, occorre costruire una vista proprietaria (custom view). È questo il caso di molte applicazioni interessanti, ad esempio di disegno, vettoriale o meno; oppure applicazioni dove si manipolano elementi all'interno di una finestra, eccetera. Una custom view non è altro che una sottoclasse di NSView in cui sono riscritte le parti di visualizzazione ed eventualmente di interazione con l'utente. Una custom view può essere molto semplice, oppure estremamente complicata (si pensi alla finestra di disegno di Photoshop, cosa non deve saper gestire...). In ogni caso, un bravo (e bello) programmatore Cocoa deve essere in grado di costruire le proprie viste; è quello che comincio a fare.

figura 01

figura 01

La cosa più semplice per cominciare è di utilizzare Interface Builder e piazzare dove necessario un oggetto CustomView (lo si trova predisposto nella palette degli oggetti). Nel mio caso, piglio il NIB CoversWin.nib relativo alla finestra CoverWinCtl e sostituisco la outlineView con una customView. In realtà il passaggio è duplice. Poiché all'interno della vista vorrò farci stare il disegno di una copertina di un CD, mi occorre un certo spazio su schermo che non è detto la finestra mi renda completamente disponibile. È giocoforza rendere la nuova customView una sottovista di una NSScrollView.

figura 02

figura 02

Ricordando la gerarchia delle viste discussa nel capitolo precedente, mi trovo ad avere una finestra CoverWinCtl che contiene due sottoviste: la barra di stato ed una NSClipView. La NSClipView, tra le altre, possiede due sottoviste: la tableView con l'elenco dei volumi e la NSScrollView. Questa contiene la NSClipView, eventuali barre di scorrimento laterali e righelli; la NSClipView, finalmente, contiene la customView.

figura 03

figura 03

Tuttavia, non posso lasciare la customView in questo stato. È bene che dichiari una nuova sottoclasse di NSView, che chiamerò CoverView; forzo la customView ad essere un oggetto di questa classe. Si tratta di una operazione tipica per ogni customView: devo per ognuna dichiarare la classe di appartenenza; essendo appunto custom, molte volte sarà dichiarata al momento.

figura 04

figura 04

figura 05

figura 05

C'è una cosa cui deve essere posta la massima attenzione, pena la perdita di molto tempo alla ricerca dei motivi di comportamenti bizzarri: il dimensionamento della vista e la disposizione automatica sullo schermo. Ora, quando modifico le dimensioni della finestra, mi piacerebbe che la barra di stato rimanga della stessa dimensione verticale (ma orizzontalmente continua a coprire la maggior parte dello spazio disponibile), mentre la NSSplitView si adatti a coprire la maggior parte della superficie disponibile. All'interno della splitView la tableView e la scrollView dovrebbero spartirsi la spazio proporzionalmente. Il corretto funzionamento di tutto ciò è garantito dalla presenza delle molle e delle barre nelle caratteristiche di dimensionamento dell'oggetto relativo. All'inizio avevo pensato che anche la CoverView all'interno della scrollView dovesse avere le stesse caratteristiche. È evidente a tutti (a posteriori, ovviamente) che ciò non è vero: la CoverView si trova all'interno di una scrollView proprio perché la sua (della CoverView) dimensione non vuole dipendere dallo spazio che la scrollView mette a disposizione (oppure, con altre parole: perché l'accoppiata NSScrollview/NSClipView mette a disposizione un'apertura di dimensione limitata attraverso la quale vedere la più grande view interna). In definitiva, è fondamentale che le caratteristiche di dimensionamento della CoverView siano sempre fisse, ovvero siano sempre barre e mai molle, pena il mancato funzionamento della scrollView. Le dimensioni effettive della CoverView, sebbene impostabili da interfaccia, preferisco attribuirle da programma, alla creazione della vista stessa (dipenderanno dal tipo di copertina che si vuole disegnare).

Già che sono in Interface Builder, ho aggiunto un pulsante all'interfaccia giusto per avere un meccanismo per inviare messaggi in giro ai vari oggetti.

Completo le operazioni aggiungendo un po' di collegamenti fra i vari elementi presenti e costruendo i file per la classe CoverView.

La CoverView

Devo adesso scrivere i metodi per realizzare la CoverView. I metodi essenziali sono due:

- (id)initWithFrame:(NSRect)frameRect ;
- (void)drawRect:(NSRect)rect ;

Il primo è utilizzato per mettere a posto le cose la prima volta che è visualizzata la view:

- (id)initWithFrame:(NSRect)frameRect
{
    if ((self = [super initWithFrame:frameRect]) != nil)
    {
        // inizializzazioni particolari
    }
    return self;
}

Come si può vedere, per il momento è piuttosto scarna. è importante tuttavia la chiamata alla classe super tutte le inizializzazioni standard. L'argomento del metodo è il rettangolo frame, ovvero la posizione di questa vista all'interno della sua superview, espresso nelle coordinate della superview.

Il secondo metodo è invece il metodo fondamentale per la visualizzazione della CoverView. È bene racchiudere qui dentro ogni istruzione di disegno, dal momento che questo metodo è automaticamente chiamato da Cocoa quando la vista deve essere ridisegnata in seguito a qualche accadimento (ridimensionamento della finestra, spostamento delle barre di scorrimento, eccetera). L'argomento è il rettangolo che deve essere ridisegnato a video. Può quindi essere utilizzato per evitare di ridisegnare parti della vista che nessuno vedrà.

Ma prima di esaminare il codice di questo metodo, operazione cui è dedicato il resto del capitolo, occorre fare una premessa ed un passo indietro.

Contenuto della CoverView

Non ho ancora chiarito cosa intendo disegnare all'interno della CoverView. Per il momento mi limito a fare qualche esperimento. L'idea è comunque di avere un ambiente di progettazione grafica delle copertine di un CD; in questo ambiente una delle caratteristiche principali sarà quella di rappresentare l'elenco dei file presenti sotto forma della outlineView (limitatamente ai file visualizzati e con la caratteristica is2Print a vero).

In questi momenti iniziali, proverò tuttavia a disegnare il retro di una copertina di un CD e sperimentare un po' con il disegno di vari elementi grafici all'interno della view. Mi sono munito di righello, ed ho misurato una copertina, ottenendo alcune dimensioni che ho raccolto in una serie di #define (che evito di riportare qui, ma sono presenti nel file CoverView.h). Tutte le mie dimensioni sono in centimetri; poiché, a quanto pare, le coordinate di Quartz sono pixel, prima di ogni istruzione trasformo le unità centimetri in unità pixel utilizzando una macro.

#define        XFORM_FACTOR        28.346
#define        CM2PX( val )        ( val * XFORM_FACTOR)

In tutto ciò presuppongo che ci siano 72 pixel per pollice; con qualche altro fattore di conversione, si arriva a 28.346 pixel per centimetro. Evito di inserire dimensioni sotto forma numerica direttamente nel codice, ma uso piuttosto #define e macro proprio perché non sono sicuro dei valori e delle unità di conversione; se dovessero risultare sbagliate, basta correggere i numeri in pochi posti e tutto funzionerà a meraviglia.

Dato che uno degli elementi principali sarà una outlineView, ho provveduto ad aggiungere la variabile d'istanza catView alla CoverView di classe NSOutlineView. Per il momento decido che la outlineView sarà sempre presente, e creata all'inizio dei tempi. Il problema della outlineView è che ha bisogno di molto supporto da altri oggetti dell'applicazione; può quindi essere costruita solo dopo che gli oggetti di cui necessita sono stati costruiti. Poiché la CoverView è costruita prima di altri, il posto più adatto per la sua costruzione è all'interno del metodo windowDidLoad di CoverWinCtl.

Tutto ciò giustifica la revisione di questo metodo come segue:

- (void) windowDidLoad
{
    NSTableColumn        * tableColumn1 = nil;
    NSTableColumn        * tableColumn2 = nil;
    ImageAndTextCell    * imageAndTextCell1 = nil;
    ImageAndTextCell    * imageAndTextCell2 = nil;
    CatDataSrc            * myDataSrc ;
    float                x1, x2, y1, y2 ;
    NSRect                outRect ;
    NSOutlineView        * catView = nil ;
    
    [super windowDidLoad ];
    // associo questa finestra alla sorgente dati
    myDataSrc = [ [self document] dataSource] ;
    [ volList setDelegate: myDataSrc ];
    [ volList setDataSource: myDataSrc ];
    // salvo nelle prefs le dimensioni delle colonne della outlineView
    [ volList setAutosaveName: @"VolumeViewOptions" ];
    tableColumn1 = [volList tableColumnWithIdentifier: @"volumeList"];
    // creo l'oggetto autorelease perche' passa in carico alla tableColumn
    imageAndTextCell1 = [[[ImageAndTextCell alloc] init] autorelease];
    [imageAndTextCell1 setEditable: NO];
    [tableColumn1 setDataCell:imageAndTextCell1];
    
    // metto a posto la dimensione della view della copertina
    [cvrView setFrameSize:NSMakeSize(CM2PX(VIEW_DIM_HOR), CM2PX(VIEW_DIM_VEL))];

    x1 = STARTPOINT_X + CUTSIGN_DIM + CUTSIGN_DIST + CUTSIGN_DIM ;
    y1 = STARTPOINT_Y + CUTSIGN_DIM + CUTSIGN_DIST + CUTSIGN_DIM ;
    x2 = BACK_OUTER_HOR_DIM - 2 * CUTSIGN_DIM ;
    y2 = BACK_OUTER_VER_DIM - 2 * CUTSIGN_DIM ;    
    outRect = NSMakeRect(CM2PX(x1), CM2PX(y1), CM2PX(x2), CM2PX(y2)) ;
    catView = [[ NSOutlineView alloc] initWithFrame: outRect ] ;
    [ catView autorelease ];
    // creo la NSTableColumn con l'identificatore
    tableColumn2 = [ [NSTableColumn alloc] initWithIdentifier: COLID_FILENAME ];
    imageAndTextCell2 = [[[ImageAndTextCell alloc] init] autorelease];
    [ tableColumn2 setDataCell:imageAndTextCell2];
    [ tableColumn2 setResizable: TRUE ];
    [ tableColumn2 setWidth: CM2PX(y2)] ;
    // imposto il titolo della colonna
    [ [ tableColumn2 headerCell ] setStringValue: NSLocalizedString( COLID_FILENAME, COLID_FILENAME ) ];
    [ catView addTableColumn: tableColumn2 ];        
    [ catView setTag: TAG_CDCATDOC_FILELIST ];
    [ catView setDataSource: myDataSrc ];
    [ catView setDelegate: myDataSrc ];
    [ catView setUsesAlternatingRowBackgroundColors: TRUE ];
    [ catView setDrawsGrid: TRUE ];
    [ catView setBackgroundColor: [ NSColor redColor] ];

    [ cvrView setWinCtl: self ];
    [ cvrView setCatView: catView ];
    [ cvrView addSubview: catView ];
}

All'inizio ci sono le note istruzioni per la configurazione della tableColumn con la lista dei volumi. La prima istruzione degna di interesse è quella che dimensiona la CoverView (cui cvrView punta: è un outlet che ho aggiunto proprio allo scopo). Successivamente c'è una lunga serie di istruzioni necessarie alla costruzione della outlineView. Poiché non c'è più Interface Builder a darmi una mano, devo specificare tutto: quanto grande è, quali colonne e come sono fatte, quale sia il tag della outlineView (indispensabile per la visualizzazione), ovviamente la classe dataSource e delegata.

Al termine, oltre ad aggiustare un paio di collegamenti, c'è la fondamentale istruzione che dice di aggiungere la outlineView come una sottovista della CoverView. Solo in questo modo, infatti, la outlineView riceve i messaggi di ridisegno (che poi scatenano tutte le richieste alla classe dataSource).

Disegnare in una view

Eccomi arrivato al metodo principale di questo capitolo, ovvero come disegnare all'interno di una view. Il metodo è piuttosto lungo ed in alcune sue parti noioso; ne riporto qui alcuni spezzoni interessanti:

- (void)drawRect:(NSRect)rect
{
    float x1, x2, y1, y2 ;
    NSFont * locFont ;
    NSMutableDictionary *strAttr ;
    // sfondo bianco
    [[ NSColor whiteColor] set ];
    NSRectFill( rect );
    // linee di taglio in rosso
    [[ NSColor redColor] set ];
    // due linee di taglio inferiori verticali
    x1 = STARTPOINT_X + CUTSIGN_DIM + CUTSIGN_DIST ;
    y1 = STARTPOINT_Y ;
    x2 = STARTPOINT_X + CUTSIGN_DIM + CUTSIGN_DIST ;
    y2 = STARTPOINT_Y + CUTSIGN_DIM ;    
    [ NSBezierPath strokeLineFromPoint: NSMakePoint( CM2PX(x1), CM2PX(y1) ) toPoint: NSMakePoint( CM2PX(x2), CM2PX(y2) ) ];

La prima cosa da fare è riempire lo sfondo della finestra. Sono già fermo: occorre specificare il colore.

Un colore è un oggetto della classe NSColor. Esistono moltissimi metodi per definire un colore, e whiteColor è uno di questi: quindi l'istruzione

[ NSColor whiteColor]

produce un oggetto della classe NSColor, che rappresenta il colore bianco. Inviando ad un colore il messaggio set si costringe ogni successivo comando di disegno ad utilizzare il colore indicato.

Per riempire un rettangolo con un dato colore, ho scoperto esistere due diverse modalità; la prima è quella di utilizzare un oggetto NSBezierPath con l'istruzione

[ NSBezierPath fillRect: rect ];

La seconda possibilità, utilizzata nell'esempio, è di utilizzare la funzione di libreria dello Application Kit NSRectFill. Ignoro quale sia la differenza tra le due diverse modalità; l'effetto è comunque lo stesso.

Comincio poi una lunga serie di istruzioni in cui disegno una serie di linee (le chiamo di taglio in quanto dovrebbero aiutare il ritaglio della copertina una volta stampata) di colore rosso. Il metodo utilizzato è quello per disegnare un linea per due punti strokeLineFromPoint:toPoint:.

Al termine, disegno in nero un rettangolo e due linee verticali che individuano le due aree laterali riservate generalmente al titolo del CD.

Comincio poi una serie di blocchi di istruzioni per mio divertimento.

    x1 = STARTPOINT_X + CUTSIGN_DIM + CUTSIGN_DIST + BACK_OUTER_HOR_DIM / 2 ;
    y1 = STARTPOINT_Y + CUTSIGN_DIST ;
    locFont = [ NSFont fontWithName: @"Arial" size: 16 ] ;
    strAttr = [ NSMutableDictionary dictionary] ;
    [ strAttr setObject: locFont forKey: NSFontAttributeName ];
    [ strAttr setObject: [NSColor greenColor] forKey: NSForegroundColorAttributeName ];
    [ @"back cover" drawAtPoint: NSMakePoint( CM2PX(x1), CM2PX(y1) ) withAttributes: strAttr ];

In questo primo blocco provo a scrivere una stringa di testo; per fare questo, produco un oggetto di classe NSFont in cui sono racchiuse le informazioni di carattere (tipo e dimensione). Questa informazione, assieme ad altre caratteristiche del testo (qui, ad esempio, il colore, ma ce ne sono tantissime) sono inserite in un dizionario di attributi del testo. Il tutto contribuisce all'istruzione finale del blocco, in cui la stringa 'back cover' sarà scritta a partire da un certo punto.

In effetti per molti scopi il metodo drawAtPoint:withAttributes: è sufficiente. Non sono tuttavia riuscito a scoprire un metodo per ruotare il testo di novanta gradi, in modo da inserirlo all'interno dell'area laterale del titolo. Cercando nella documentazione, ho trovato un esempio che permette di scrivere testo lungo un percorso qualsiasi (per Bacco!) con non troppo sforzo. Non mi voglio addentrare nei dettagli (che non mi interessano), ma solo capire a grandi linee cosa succede.

Ci sono tre classi che partecipano a quest'unico scopo: NSTextStorage, NSLayoutManager e NSTextContainer. La prima classe fornisce uno strumento per conservare del testo, con tutti gli attributi che si vogliono per ogni singolo elemento. Potrei dichiararla una classe 'modello' (nel senso del paradigma MVC) di un elemento di testo. La classe NSTextContainer funziona come una classe 'vista' (sempre per MVC), definendo una regione di spazio dove il testo deve essere rappresentato. Chi controlla la rappresentazione del testo è infine la classe NSLayoutManager, che coordina la disposizione e la visualizzazione dei singoli elementi di testo.

Per far funzionare tutto questo accrocchio, occorre in primo luogo costruire l'ambiente di lavoro. Ciò avviene (nella fattispecie, potrebbe non essere il posto migliore) all'interno del metodo initWithFrame:

- (id)initWithFrame:(NSRect)frameRect
{
    if ((self = [super initWithFrame:frameRect]) != nil)
    {
        textStorage = [[NSTextStorage alloc] initWithString:@"This is the place for the cd title"];
        layoutManager = [[NSLayoutManager alloc] init];
        textContainer = [[NSTextContainer alloc] init];
        [layoutManager addTextContainer:textContainer];
        [textContainer release];
        [layoutManager setUsesScreenFonts:NO];
        [textStorage addLayoutManager:layoutManager];
        [layoutManager release];        
    }
    return self;
}

Ho definito tre variabili d'istanza (textStorage, layoutManager e textContainer) per semplificarmi la vita. Per prima cosa costruisco questi tre oggetti, poi costruisco i collegamenti tra le variabili d'istanza (il codice è copiato di peso dall'esempio). Di seguito, quando devo visualizzare la stringa, procedo come segue (sono tornato all'interno del metodo drawRect:):

    NSGraphicsContext *context = [NSGraphicsContext currentContext];
    NSAffineTransform *transform = [NSAffineTransform transform];
    NSRange glyphRange = [layoutManager glyphRangeForTextContainer: textContainer ];
    x1 = STARTPOINT_X + CUTSIGN_DIM + CUTSIGN_DIST + BACK_LABEL_HOR_DIM;
    y1 = STARTPOINT_Y + CUTSIGN_DIM + CUTSIGN_DIST ;
    [ transform translateXBy:CM2PX(x1) yBy:CM2PX(y1) ];
    x2 = 90 ;
    [ transform rotateByDegrees: x2];
    [context saveGraphicsState];
    [transform concat];
    [ layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:NSMakePoint(5, 0)];
    [context restoreGraphicsState];

In questa sezione di codice temo ci siano concetti che potrebbero impegnare giorni per essere spiegati compiutamente; anche qui, mi darò una giustificazione più o meno funzionale, giusto per capire come fare. Parto dall'oggetto NSGraphicsContent. Ogni istruzione di disegno avviene all'interno di un contesto grafico, un oggetto che raccoglie tutte le caratteristiche di disegno (spessore delle linee, colore di background, sistema di coordinate, cose del genere, penso). Il motivo della presenza di questo oggetto è di poter bloccare per un momento il sistema di riferimento, per poi ripristinarlo al termine delle operazioni di disegno (la coppia saveGraphicsState e restoreGraphicsState). Infatti, per poter eseguire con comodo il disegno della stringa in senso verticale, effettuo un cambio nel sistema di riferimento. È questo lo scopo dell'oggetto della classe NSAffineTransform: la variabile transform permette di modificare il sistema di riferimento (qui, se non avete mai fatto un po' di geometria non ne venite fuori facilmente). Prima sposto l'origine sul punto base del rettangolo che delimita l'area riservata al titolo, poi ruoto il tutto di novanta gradi. Il messaggio concat permette di applicare queste trasformazioni in catena a quelle standard (che sono già state predisposte dall'ambiente operativo in quanto sto disegnando all'interno di una view). Finalmente posso dare il comando di disegno del testo con il metodo drawGlyphsForGlyphRange:atPoint:, che dice di disegnare gli elementi del testo (i glifi) nel range specificato a partire dal punto indicato (che ho spostato di 5 pixel giusto per non stare troppo vicino al rettangolo delimitante). Il range indicato (un range individua una serie di elementi all'interno di una lista ordinata) è ottenuto tramite il metodo glyphRangeForTextContainer: il quale, a quanto pare, effettua anche il 'rendering' interno dei caratteri in base alla regione specificata (che, non avendo io specificato, sarà di default una linea infinita).

C'è un'ultima cosa che voglio fare prima di concludere: disegnare un'immagine predefinita all'interno della vista.

    NSImage * locImg ;
    locImg = [NSImage imageNamed: @"Add"] ;
    x1 = STARTPOINT_X + CUTSIGN_DIM + CUTSIGN_DIST + BACK_OUTER_HOR_DIM / 2 ;
    y1 = STARTPOINT_Y + CUTSIGN_DIST+ CUTSIGN_DIST + BACK_OUTER_VER_DIM / 2 ;
    [ locImg compositeToPoint:NSMakePoint( CM2PX(x1), CM2PX(y1) )
            operation: NSCompositeSourceOver ];

Qui riciclo una delle immagini della toolbar (giusto perché era sotto mano). Costruisco un oggetto della classe NSImage e poi, banalmente, uso un metodo apposito. Per il momento, nulla di più facile.

A testa in sù

figura 06

figura 06

Il sistema di riferimento su cui si basa l'intero sistema di coordinate di Cocoa è particolarmente originale. Molti altri ambienti operativi utilizzano una convenzione differente (anche Mac OS 9): l'origine del sistema di riferimento è in alto a sinistra, l'asse delle ascisse viaggia verso destra e l'asse delle ordinate verso il basso. Benché Cocoa utilizzi un sistema di riferimento più matematico e naturale, in realtà è piuttosto innaturale per l'uso invalso in informatica. Ciò è particolarmente evidente quando la scrollview diventa più grande della CoverView; lo sfondo della scrollView non coperta dalla CoverView (che ho appositamente mantenuto di un colore grigio piuttosto diverso dal bianco utilizzato come sfondo per la CoverView) cresce in alto e a destra. In effetti, sono abituato a vederlo crescere in basse e destra.

Senza dover diventare matto a cambiare ogni singola coordinata, c'è un meccanismo molto semplice per rovesciare tutte le coordinate verticali. Basta scrivere il metodo seguente:

- (BOOL)isFlipped {
    return YES;
}

figura 07

figura 07

La realizzazione di default di questo metodo restituisce NO. Dicendo invece YES si costringe Quartz a rovesciare l'asse delle ordinate, spostando appunto l'origine del sistema di riferimento dal basso-a-sinistra in alto-a-sinistra.

Licenza Creative Commons
Eccetto dove diversamente specificato, i contenuti di questo sito sono rilasciati sotto Licenza Creative Commons.
Pagina a cura di Livio Sandel (macocoa2012@gmail.com).