MaCocoa 041

Capitolo 041 - Costruisco Elementi

Prima di avventurarmi nel disegno interattivo di elementi grafici della coverView, faccio un po' di esercizio costruendo una finestra che dipinge elementi di vario tipo.

Nulla di nuovo, uso documentazione Apple e piglio ispirazione da Sketch.

Primo inserimento: 4 febbraio 2004

Alcune Piccole Modifiche

Per prima cosa, ripulisco un po' il codice e modifico alcune classi. Nella inizializzazione della coverView, elimino il gruppo di elementi, alcune scritte e l'immagine. Anzi, elimino del tutto la classe CCE_Image, che tanto era imperfetta e verosimilmente la riprenderò più avanti, con più completezza.

Poi passo a trattare meglio la classe base: aggiungo i metodi accessor per la variabile cceIsFilled, la variabile cceIsStroked (intendendo con essa indicare se si devono tracciare i bordi di figure oppure no) e i relativi metodi accessor.

Appare ovvia poi l'imposizione a TRUE di questa variabile per tutti gli oggetti della classe CCE_Line. Avendo aggiunto la variabile cceIsStroked, ci sono delle modifiche sul metodo di disegno di CCE_Rect:

- (void) drawElement: (NSRect) inRect
{
    if ( cceIsStroked )
    {
        // imposto il colore del contorno
        [ cceLineColor set ];
        // imposto lo spesosre del contorno
        [ theRect setLineWidth: [ self cceLineWidth ]];
        // disegno il contorno
        [ theRect stroke ];
    }
    // se il rettangolo e' pieno
    if ( cceIsFilled )
    {
        // imposto il colore del pieno
        [ cceFillColor set ];
        // riempi tutto
        [ theRect fill ];
    }
}

Non c'è molto da dire qui: da notare che non faccio alcun controllo che almeno una tra le variabili d'istanza cceIsStroked e cceIsFilled sia a TRUE; fossero entrambi FALSE, il rettangolo in pratica non è disegnato...

A questo punto, aggiungo un nuovo elemento grafico, che viene fuori facile una volta messo a punto il rettangolo: l'ellisse (di cui il cerchio è un caso particolare). Dico infatti che per determinare un'ellisse basta specificare il rettangolo in cui è inscritta.

- (id) initWithRect: (NSRect) aRect ;
{
    self = [ super init ];
    if ( self )
    {
        // per mia convenienza conservo il percorso
        NSBezierPath *path = [NSBezierPath bezierPathWithOvalInRect: aRect ];
        [ self setTheCircle: path ];
        // metto a posto il rettangolo che lo contiene
        [self setElemLimit: aRect ];
    }
    return ( self );
}

Continua a non esserci molto da dire.

Il Fabbricatore d'Elementi

Il metodo più naturale per disegnare elementi all'interno della coverView è ovviamente quello di utilizzare il mouse e tracciare l'elemento che interessa, come ogni buona interfaccia grafica deve fare. Tuttavia, mi pare che il problema non sia particolarmente semplice. Comincio allora col preparare una finestra ausiliaria che consenta di aggiungere elementi grafici alla coverView stabilendo per questi elementi i parametri di inizializzazione richiesti.

Allo scopo prelevo di peso una finestra dall'applicazione Sketch negli esempi di Apple, la copio all'interno della mia applicazione, ne modifico l'aspetto e soprattutto la funzione.

figura 01

figura 01

In primo luogo, ho un menu pop-up al quale attribuisco tre voci: Line, Rect, Circle. Con questo controllo stabilisco il tipo da elemento da inserire. Ci sono poi due pulsanti di spunta e due NSColorWell per attribuire il colore del contorno e del riempimento. I pulsanti di spunta stabiliscono col loro stato se il contorno o il riempimento va tracciato, mentre i due NSColorWell stabiliscono il colore.

Il controllo NSColorWell è particolarmente ricco. In maniera assolutamente trasparente per il programmatore consente all'utente si scegliere un colore con uno dei metodi standard del sistema operativo. Per il programmatore, è possibile reagire nel momento della selezione del colore, oppure lasciar perdere e recuperare il colore da utilizzare all'ultimo momento utile. È proprio questa la strategia che sarà da me utilizzata.

figura 02

figura 02

C'è poi un doppio controllo per scegliere lo spessore del contorno: uno slider cui è associato un campo testo. Il gioco dei collegamenti fra questi due controlli fa sì che l'impostazione dello spessore sia possibile sia da slider che da campo di testo.

Infine, ci sono quattro campi di testo all'interno dei quali scrivere quattro coordinate, o meglio due coppie di valori. Senza stare troppo a sottilizzare (questa finestra sparirà o cambierà sostanza spero molto presto), nel caso di linea, si determinano i due punti, iniziale e finale, della linea. Nel caso di rettangolo o ellisse, il punto di partenza e le due dimensioni lunghezza e larghezza.

Ogni singolo controllo dell'interfaccia va collegato all'interno della classe che controlla l'aspetto della finestra, in quanto ad un certo punto sarà necessario recuperare tutti i valori per stabilire come tracciare l'elemento grafico. Completa poi l'interfaccia un bel pulsante di Create per costruire direttamente l'elemento grafico. Qui ci va un collegamento del tipo target/action, in modo da scatenare la costruzione dell'elemento grafico. Dicevo appunto della classe cui appartiene il controllore della finestra. Dichiaro la nuova classe ObjMakerWinCtl, che risulta così stabilita:

@interface ObjMakerWinCtl : NSWindowController {
    IBOutlet NSButton        * fillCheckbox;
    IBOutlet NSColorWell    * fillColorWell;
    IBOutlet NSButton        * lineCheckbox;
    IBOutlet NSColorWell    * lineColorWell;
    IBOutlet NSSlider        * lineWidthSlider;
    IBOutlet NSTextField    * lineWidthTextField;
    IBOutlet NSTextField    * xTextField;
    IBOutlet NSTextField    * yTextField;
    IBOutlet NSTextField    * widthTextField;
    IBOutlet NSTextField    * heightTextField;
    IBOutlet NSPopUpButton    * classSelection;
    // puntatore alla view della finestra dove si disegna
    CoverView                * theDrawView ;
    IBOutlet NSButton        * createButton;
}

+ (id)sharedObjMakerWinCtl;

- (IBAction)createObject:(id)sender;
...
@end

Per richiamare questa finestra, opero nel solito modo: aggiungo nel file MainMenu.nib una nuova voce di menu, appunto per mostrare questa finestra. Va da sé che aggiungo nella classe AppDelegate un nuovo metodo

- (IBAction)showObjectMaker:(id)sender;

che serve a costruire la solita istanza, condivisa a tutta l'applicazione, della finestra fabbricatrice di elementi ObjMakerWinCtl.

C'è poi da inizializzare il tutto con il solito metodo:

- (void)windowDidLoad
{
    [super windowDidLoad];
    [fillCheckbox setState:NSOffState];
    [fillCheckbox setEnabled:YES];
    [fillColorWell setColor:[NSColor whiteColor]];
    [fillColorWell setEnabled:YES];
    [lineCheckbox setState:NSOnState];
    [lineCheckbox setEnabled:YES];
    [lineColorWell setColor:[NSColor blackColor]];
    [lineColorWell setEnabled:YES];
    [lineWidthSlider setFloatValue:0.0];
    [lineWidthSlider setEnabled:YES];
    [lineWidthTextField setFloatValue:0.0];
    [lineWidthTextField setEnabled:YES];
    [xTextField setStringValue:@""];
    [xTextField setEnabled:YES];
    [yTextField setStringValue:@""];
    [yTextField setEnabled:YES];
    [widthTextField setStringValue:@""];
    [widthTextField setEnabled:YES];
    [heightTextField setStringValue:@""];
    [heightTextField setEnabled:YES];
    ...
}

Qui mancano le ultime tre istruzioni, che meritano un paragrafo a parte

Colpire il Giusto Target

Ora, abbiamo il solito problema di una finestra condivisa a tutta l'applicazione che però opera direttamente su una delle tante finestre disponibili. Qui la cosa è vieppiù complicata dal fatto che la finestra davanti a tutti, in cui dovrebbe operare il fabbricatore, può essere corretta (una CoverWinCtl) o sbagliata (una ListWinCtl).

Per gestire questa situazione, utilizzo la variabile d'istanza theDrawView, che contiene la coverView correntemente attiva; userò la convenzione d'impostarla a nil ogni volta che la finestra di fronte a tutte le altre non contiene la coverView. Per gestire dunque il valore di questa variabile, ricorro ancora una volta (lo avevo già fatto con la finestra InfoWinCtrl) alle notifiche NSWindowDidBecomeMainNotification e NSWindowDidResignMainNotification inviate dall'ambiente operativo quando cambia la finestra di fronte a tutte le altre.

Per sfruttarle correttamente, occorre aggiungere per due volte (una per ciascuna notifica) la finestra ObjMakerWinCtl come osservatore. Bisgna aggiungere le due seguenti istruzioni al metodo windowDidLoad:

    [[NSNotificationCenter defaultCenter] addObserver:self
        selector:@selector(mainWindowChanged:)
        name:NSWindowDidBecomeMainNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self
        selector:@selector(mainWindowResigned:)
        name:NSWindowDidResignMainNotification object:nil];

In questo modo, quando la finestra di fronte cambia, sono chiamati i metodi indicati dalla espressione @selector(.). Questi due metodi sono molto semplici:

- (void)
mainWindowChanged: (NSNotification *)notification
{
    [self setMainWindow:[notification object]];
}

- (void)
mainWindowResigned: (NSNotification *)notification
{
    [self setMainWindow:nil];
}

ma solo perché è tutto compreso nel metodo setMainWindow, che ha come parametro la finestra che è diventata quella davanti a tutti (e quindi, se non ce ne sono, si passa nil).

- (void)
setMainWindow: (NSWindow *) mainWindow
{
    NSWindowController *controller = [mainWindow windowController];
    // se c'e' una finestra ed e' del tipo giusto
    if (controller && [controller isKindOfClass:[CoverWinCtl class]])
    {
        theDrawView = [(CoverWinCtl *)controller cvrView];
        [ createButton setEnabled:YES];
    }
    else    
    {
        theDrawView = nil;
        [ createButton setEnabled:NO];
    }
    // qui dovrei aggiornare la finestra, se il caso
    // [self updateMe];
}

La prima istruzione eseguita cerca di recuperare il controllore della finestra a partire dalla finestra passata come parametro. Non ci fosse la finestra (cioé, il parametro mainWindow è nil), nessun problema: il messaggio windowController (anzi, qualsiasi messaggio) inviato ad un oggetto nil restituisce nil. Andiamo avanti: l'istruzione if successiva verifica se c'è il controllore della finestra, e se c'è questo controllore, se è del tipo corretto (cioè, se è una CoverWinCtl). Qui si sfrutta una caratteristica del linguaggio C e della logica simbolica: se in una espressione di due termini combinati secondo un AND, se il primo è falso, allora per certo anche l'intera espressione è falsa, e il secondo termine dell'espressione non è neppure valutato. Nel mio caso, se la finestra non c'è, si cade subito nel ramo else dell'istruzione if; ugualmente si cade lì se la finestra c'è, ma non è del tipo giusto. In questi casi, si impone a nil il valore della variabile theDrawView, e si disabilita il pulsante Create (evitando qualsiasi tentazione).

L'unico caso in cui si ha qualcosa di utile è proprio quando la finestra c'è ed è del tipo corretto. Allora si recupera in qualche modo la coverView e la si assegna alla variabile d'istanza; contestualmente si abilita il pulsante Create nel caso fosse stato disabilitato in precedenza. La presenza di un cast esplicito (l'esplicita attribuzione a controller di un tipo CoverWinCtl* ) serve solo a tranquillizzare il compilatore.

Tuttavia, c'è ancora un problema, all'apertura della finestra ObjMakerWinCtl. Questa non è correttamente inizializzata (ad esempio, il pulsante Create è abilitato) se alla prima apertura la finestra di fronte a tutte le altre è una ListWinCtl. Si rimedia prontamente al problema aggiungendo come ultima istruzione del metodo windowDidLoad una esplicita chiamata a setMainWindow con argomento la finestra davanti a tutte le altre a livello di applicazione:

    [self setMainWindow:[NSApp mainWindow]];

Ora che ci penso, questa cosa mi ricorda qualcosa... Ma prima, è meglio completare il discorso aperto sul disegno.

Disegnare col fabbricatore

A questo punto, non rimane altro che mostrare il lungo e noioso metodo per disegnare dietro esplicito comando proveniente dal fabbricatore, ovvero il metodo invocato col meccanismo target/action:

- (IBAction)createObject:(id)sender
{

    if ( theDrawView == nil )
    {
        NSBeep( );
        return ;
    }
    // se arrivo qui, ho un posto dove disegnare
    switch ( [[ classSelection selectedItem] tag ] ) {
    case 10 : // disegno una linea
        {
            float            x1, y1, x2, y2 ;
            CCE_Line        * tmpLine ;

            // recupero le informazioni di colore
            x1 = [ xTextField floatValue ] ;
            y1 = [ yTextField floatValue ] ;
            x2 = [ widthTextField floatValue ] ;
            y2 = [ heightTextField floatValue ] ;    
            tmpLine = [ [ CCE_Line alloc] initStartPt: NSMakePoint( CM2PX(x1), CM2PX(y1) )
                            toPt: NSMakePoint( CM2PX(x2), CM2PX(y2) ) ] ;
            [ tmpLine setCceLineColor: [ lineColorWell color] ];
            [ tmpLine setCceLineWidth: [ lineWidthTextField floatValue ] ];
            // recupero la vista
            [ [theDrawView theElements] addElem: tmpLine ];
            [theDrawView setNeedsDisplayInRect: [theDrawView bounds] ];        
        }
        break ;
    case 11 : // disegno un rettangolo
        {
            float            x1, y1, x2, y2 ;
            CCE_Rect        * tmpRect ;

            // recupero le informazioni di colore
            x1 = [ xTextField floatValue ] ;
            y1 = [ yTextField floatValue ] ;
            x2 = [ widthTextField floatValue ] ;
            y2 = [ heightTextField floatValue ] ;    
            tmpRect = [ [ CCE_Rect alloc] initWithRect:
                NSMakeRect( CM2PX(x1), CM2PX(y1), CM2PX(x2), CM2PX(y2) ) ] ;
            [ tmpRect setCceLineColor: [ lineColorWell color] ];
            [ tmpRect setCceFillColor: [ fillColorWell color] ];
            [ tmpRect setCceIsFilled: [fillCheckbox state ] ];            
            [ tmpRect setCceIsStroked: [lineCheckbox state ] ];            
            [ tmpRect setCceLineWidth: [ lineWidthTextField floatValue ] ];

            [ [theDrawView theElements] addElem: tmpRect ];
            [theDrawView setNeedsDisplayInRect: [theDrawView bounds] ];        
        }
        break ;
    case 12 : // disegno un cerchio
        {
            float            x1, y1, x2, y2 ;
            CCE_Circle        * tmpRect ;

            // recupero le informazioni di colore
            x1 = [ xTextField floatValue ] ;
            y1 = [ yTextField floatValue ] ;
            x2 = [ widthTextField floatValue ] ;
            y2 = [ heightTextField floatValue ] ;    
            tmpRect = [ [ CCE_Circle alloc] initWithRect:
                NSMakeRect( CM2PX(x1), CM2PX(y1), CM2PX(x2), CM2PX(y2) ) ] ;
            [ tmpRect setCceLineColor: [ lineColorWell color] ];
            [ tmpRect setCceFillColor: [ fillColorWell color] ];
            [ tmpRect setCceIsFilled: [fillCheckbox state ] ];            
            [ tmpRect setCceIsStroked: [lineCheckbox state ] ];            
            [ tmpRect setCceLineWidth: [ lineWidthTextField floatValue ] ];

            [ [theDrawView theElements] addElem: tmpRect ];
            [theDrawView setNeedsDisplayInRect: [theDrawView bounds] ];        
        }
        break ;
    default :
        break ;
    }
}

Il metodo non merita molti commenti; per ogni tipo di oggetto, si recuperano le informazioni dalla finestra con gli appositi metodi, e si costruisce l'elemento con i dati recuperati; poi si aggiunge l'elemento alla lista degli elementi, e si finisce col forzare il ridisegno della finestra. Nulla di più banale.

figura 03

figura 03

Un Errore Corretto

Dicevo prima che l'esplicito richiamo del metodo setMainWindow: mi ricordava qualcosa; in effetti, ho controllato: ho lo stesso problema con la finestra InfoWinCtl, per visualizzare le informazioni di un file selezionato nella outlineView della finestra ListWinCtl. Se infatti apro la finestra InfoWinCtl, questa non è sempre correttamente aggiornata. Manca anche qui una esplicita prima attributzione dello stato della stessa. Il problema si risolve nello stesso modo: aggiungendo in fondo al metodo windowDidLoad qualche istruzione. Qui non ho una semplice variabile d'istanza da aggiornare, ma una intera finestra da riempire con dati: le operazioni sono già raccolte all'interno del metodo updateInfo:, che andrà correttamente invocato:

        // pero' devo mettere a posto la prima volta
        // recupero la finestra di fronte a tutti
        NSWindowController *winCtl ;
        winCtl = [[NSApp mainWindow] windowController] ;    
        
        if (winCtl && [winCtl isKindOfClass:[ListWinCtl class]])
            [ self updateInfo: [ (ListWinCtl *)winCtl getMainView ] ];

La logica di funzionamento è esattamente quella vista prima. C'è poi anche la modifica corrispondente nel metodo

- (void )
mainWindowChanged: ( NSNotification *) notification
{
    NSWindowController *winCtl ;
    winCtl = [[ notification object] windowController] ;    
    if (winCtl && [winCtl isKindOfClass:[ListWinCtl class]])
            [ self updateInfo: [ (ListWinCtl *)winCtl getMainView ] ];
    else    [self updateInfo: nil];
}

in modo che la finestra torni, ad esempio, vuota quando davanti a tutte è posta una finestra CoverWinCtl.

Ulteriori correzioni

La fase di compilazione produce un elevato numero di warning. Come è noto, sono molto infastidito da questi messaggi, che aborro quasi più degli errori. Ecco quindi che ho cercato di eliminarne un po', dove riuscivo. Molti avvertimenti derivano dal fatto che invio dei messaggi ad un oggetto non riconosciuto come corretto destinatario dal compilatore. Ora, è ben vero che il late binding passa sopra allegramente a queste cose, purché alla resa dei conti il messaggio sia recepito dall'oggetto (come effettivamente qui accade). Trovo tuttavia stupido costringere l'applicazione a queste segnalzioni quando è invece ben chiara la situazione. L'uso di un cast esplicito (come già discusso qualche riga fa) elimina molti problemi.

Altre volte, invece, il polimorfismo è in realtà solo un metodo per il programmatore per essere svogliato e non pensare. In questo caso, conviene invece migliorare il codice. È quello che penso di aver fatto modificando il meccanismo con cui il documento tiene conto delle diverse finestre che a lui fanno riferimento. Piuttosto che lasciare i controllori ListWinCtl e CoverWinCtl anominimi dentro il vettore dei windowControllers, li ho esplicitati in due variabili d'istanza:

    // le due finestre associate al documento
    ListWinCtl        * listWin ;
    CoverWinCtl        * coverWin ;

che sono esplicitamente gestite. La prima variabile non è particolarmente eccitante; è inizializzata al suo valore all'apertura del documento, e tale rimane. La seconda variabile invece è nil se la finestra non è ancora aperta, ha un vero valore se la finestra è presente.

Queste variabili hanno introdotto alcune modifiche in alcuni metodi:

- (void)
refreshWinsWithListSts: (NSString*) sts1Bar coverSts: (NSString*) sts2Bar
{
    // al massimo ci sono due finestre: questa c'e' sicuro
    [ listWin refreshWindow ] ;
    [ listWin setStsStrings: sts1Bar andSts: sts2Bar] ;
    // questa invece vediamo
    if ( coverWin != nil )
    {
        [ coverWin refreshWindow ] ;
        [ coverWin setStsStrings: sts1Bar andSts: sts2Bar] ;
    }
}

Questo metodo è diventato molto più chiaro e semplice: listWin è sicuramente presente, ed è direttamente aggiornata. Se la variabile coverWin non è nil, allora esiste anche la finestra delle copertine, che può essere aggiornata esplicitamente. Ancora semplice è poi il metodo seguente:

- ( void)
showCoversWindow
{
    // se la finestra delle copertine non c'e'
    if ( coverWin == nil )
    {
        // la costruisco
        CoverWinCtl * ctl ;
        ctl = [[ CoverWinCtl alloc ] initWithWindowNibName: @"CoversWin" ];
        [ ctl autorelease ] ;
        [ self addWindowController: ctl ];
        [[ ctl window ] makeKeyAndOrderFront: self ];
        // attribuisco il valore della variabile
        [ self setCoverWin: ctl ];
    }
}

in cui l'unico commento che mi sento di fare è piuttosto banale: se la finestra non c'è, la si costruisce.

In realtà, le cose sono più complicate; parto da qui:

- (void)
makeWindowControllers
{
    // istanza del controllore
    listWin = [[ ListWinCtl alloc ] initWithWindowNibName: @"CdListWin" ];
    // autrelease, che tanto poi sara' ritenuta
    [ listWin autorelease ] ;
    // aggiungo la fienstra al documento
    [ self addWindowController: listWin ];
    if ( 1 )
    {
        CoverWinCtl * ctl ;
        ctl = [[ CoverWinCtl alloc ] initWithWindowNibName: @"CoversWin" ];
        [ ctl autorelease ] ;
        [ self addWindowController: ctl ];
        [[ ctl window ] makeKeyAndOrderFront: self ];
        [ self setCoverWin: ctl ];
    }
}

La variabile listWin è esplicitamente assegnata, anche se, comandata autorelease, rimane viva solo perché assegnata al vettore windowControllers. Per mia comodità (mi stufavo ogni volta all'apertura dell'applicazione a selezionare l'apposita voce di menu) apro immediatamente anche la finestra della copertina.

Da notare il fatto che assegno anche la variabile d'istanza con l'apposito accessor. Valuto il valore della famigerata variabile 'contavita' che dovrebbe tenere conto di quante volte un'oggetto è impiegato: la variabile ctl è autorelease, quindi, è come se contavita fosse ancora Zero. Aggiungendola al vettore windowController, contavita diventa 1. Attribuendola alla variabile d'istanza coverWin, contavita vale 2. Alla chiusura della finestra, l'ambiente operativo si preoccuperà di toglierla dal vettore windowController; contavita vale ancora 1. Per toglierla del tutto, a coverWin deve essere attribuito il valore nil tramite un metodo accessor in modo che il valore di contavita torni a Zero: devo quindi intercettare la chiusura della finestra, in modo da fare un altro release di coverWin. Per fare questo, mi avvalgo di un messaggio inviato al controllore alla chiusura della finestra da parte dell'utente, corrispondente al metodo seguente (è un metodo della classe delegata della NSWindow. Nel NIB tale collegamento mi pare sia eseguito di default da Interface Builder, altrimenti, bisogna eseguire esplicitamente la connessione):

- (BOOL)windowShouldClose:(id)sender
{
    [ [ self document ] setCoverWin: nil ] ;
    return ( YES );
}

Il metodo permette di fermare il processo di chiusura restituendo NO (perché ad esempio si vuole terminare qualche operazione in corso). Nel mio caso, è solo un utile momento per impostare a nil la variabile d'istanza.

In effetti, all'inizio, mi era sembrato più promettente un altro metodo delegato:

- (void)windowWillClose:(NSNotification *)aNotification;

Tuttavia, la cosa non funzionava. Eseguendo l'applicazione col debugger, ho scoperto che prima è chiamato windowShouldClose e poi windowWillClose (e fin qui è abbastanza ovvio). In effetti, tra la prima e la seconda chiamata, cambia un dettaglio essenziale: al momento dell'esecuzione del secondo metodo, la classe controllore ha perso ogni collegamento con il documento (la variabile d'istanza è nil), e quindi l'istruzione

    [ [ self document ] setCoverWin: nil ] ;

non può produrre alcun risultato sensato.

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).