MaCocoa 048

Capitolo 048 - Cancella tutto, anzi no

Come spesso accade quando si è messo mano ad un lavoro in tempi successivi e distanti, ci sono molti e diversi argomenti, più o meno compiuti, a far parte di questo capitolo. Diciamo che fondamentalmente si parla del meccanismo dello Undo (Annulla/Ripristina), che tanto bene ci ha fatto, ci fa e ci farà nell'uso normale del computer. Tuttavia, si parte da tutta altra parte, ed alla fine ci saranno altri argomenti piccoli e sparsi.

Documentazione Apple

Primo inserimento: 29 luglio 2004

Cancellare elementi

Dopo che nei capitoli precedenti mi ero occupato di spostare e ridimensionare elementi, ho pensato anche di aggiungere la possibilità di eliminarli. Per compiere questa operazione, l'utente ha a disposizione due possibilità: selezionare una voce di menu o premere qualche tasto significativo sulla tastiera (ad esempio, il tasto di cancellazione). Per la voce di menu, uso Interface Builder per associare alla voce Cancella o Clear del meni Edit il metodo clear della CoverView. Per quanto riguarda la tastiera, ricorrendo ancora una volta ai meccanismi esoterici descritti nel capitolo 45, il compito è svolto da due metodi specifici, qui di seguito rappresentati assieme al metodo clear.

- (void)deleteForward:(id)sender {
    [self clear:sender];
}

- (void)deleteBackward:(id)sender {
    [self clear:sender];
}

- (void) clear: (id)sender
{
    NSMutableArray * selArr = [ theElements getSelectedGraphics ];
    int                 numElem = [ selArr count ];
    if ( numElem == 0 )
        return ;
    [self deleteElem:sender];
}

I metodi deleteForward e deleteBackward dovrebbero corrispondere ai tasti di cancellazione all'indietro ed in avanti, presenti separatamente sulle tastiere estese (generalmente il tasto di cancellazione in avanti non è presente sui portatili, come accade a me). Entrambi si limitano ad invocare il metodo clear. A sua volta clear, piuttosto che sporcarsi le mani, verifica che ci siano degli elementi selezionati e, nel caso, chiama un ulteriore metodo deleteElem. Anche questo metodo non è troppo sicuro di sé, e quindi mostra un bel dialogo preavvertendo l'utente che sta per cancellare inesorabilmente alcuni elementi, richiedendone l'approvazione.

- (void)
deleteElem:(id)sender
{
    NSAlert *alert = [[[NSAlert alloc] init] autorelease];
    [alert addButtonWithTitle:@"OK"];
    [alert addButtonWithTitle:@"Cancel"];
    [alert setMessageText:@"Delete the selected elements?"];
    [alert setInformativeText:@"Deleted elements cannot be restored."];
    [alert setAlertStyle:NSWarningAlertStyle];
    [alert beginSheetModalForWindow:[winCtl window]
            modalDelegate:self
            didEndSelector:@selector(alertDidEnd:returnCode:contextInfo:)
            contextInfo:nil];
}

Non c'è nulla di particolare qui, si tratta del codice standard per gestire un normale Alert. Se l'utente non si è ancora stancato di tutta questa trafila, e dice di proseguire con la cancellazione, tocca al metodo indicato in precedenza tramite la direttiva @selector:

- (void)
alertDidEnd:(NSAlert *)alert
    returnCode:(int)returnCode
    contextInfo:(void *)contextInfo
{
    if (returnCode == NSAlertFirstButtonReturn)
        [ self deleteSelectedElements ];
}

Finalmente ci siamo: è chiamato il metodo deleteSelectedElements, che ci libera dalla presenza degli elementi correntemente selezionati:

- (void)
deleteSelectedElements
{
    int        i , numElem ;
    CCE_BasicForm * elem ;
    // ciclo su tutti gli elementi del gruppo
    numElem = [ [ theElements elemArray] count ];
    for ( i = (numElem-1) ; i >= 0 ; i -- )
    {
        elem = [ [ theElements elemArray] objectAtIndex: i ];
        if ( [ elem cceIsSelected ] )
        {
            [ theElements remElemAtIndex: i ];
        }
    }    
    [ self setNeedsDisplay: YES ];
}

Scrivere codice ad oggetti è come l'arte di procrastinare. Il lavoro sporco è svolto ancora una volta altrove, e precisamente dal metodo remElemAtIndex, proprio della classe CCE_ElemGroup.

- (void) remElemAtIndex: (int) i
{
    // eseguo la cancellazione
    [ elemArray removeObjectAtIndex: i ];
    [ ownerView setNeedsDisplay: YES ];
}

In un modo o nell'altro, sono alla fine. Al termine di questa catena di metodi, si arriva all'eliminazione degli elementi della copertina. Ottimo. Ma, come il dialogo avvertiva, non c'è modo di tornare indietro. O meglio, per tornare indietro, occorre realizzare un meccanismo di Annullamento e Ripristino, in una sola parola, Undo.

Annulla e Ripristina

Il meccanismo di Undo è uno dei motivi per cui si ama un computer. Scrivi una lettera, sbagli qualcosa, usi Undo e torni indietro. Sono finiti i tempi in cui si potevano commettere danni irreparabili con poco: adesso c'è sempre la possibilità di tornare sui propri passi (fosse così anche nella vita reale...). Di più, all'inizio si poteva tornare indietro di un solo passo, ma poi sempre più programmi realizzano un Undo multi-livello, attraverso il quale tornare indietro di più passi, molti, infiniti. Che bello. C'è un però. Però i programmatori dovevano diventare matti per realizzare tutto ciò; ad ogni possibile operazione dell'utente, il programmatore paranoico si deve chiedere se si può tornare indietro, e se sì, come. Ed una volta capito il come, prima di fare qualcosa, bisogna salvare lo stato corrente per essere sicuri di poterlo ripristinare. Insomma, uno sforzo titanico. Ora, con Cocoa, non è che non si debba essere paranoici. Prima che eseguire qualsiasi cambiamento, occorre sempre chiedersi se vale la pena di tornare indietro, e come. Però Cocoa mette a disposizione alcuni strumenti che semplificano la vita del programmatore (in ciò, va detto, aiutato dalla natura ad oggetti del programma).

In effetti, ogni modifica dell'utente può essere ricondotta alla modifica di uno stato di un oggetto. Quindi, prima di eseguire questa modifica, basta conservare in luogo fresco ed asciutto l'oggetto nel suo stato corrente. Meglio ancora, si conserva da qualche parte una serie di istruzioni che permettono di riportare l'oggetto allo stato corrente. Se è una macchinina-oggetto è di colore rosso, ed il bambino-utente la vuole gialla, per realizzare il meccanismo di Undo è sufficiente ricordare l'oggetto (o meglio, sapere qual è l'oggetto interessato), il metodo di colorazione, ed il parametro del metodo (rosso). Se una volta colorata di giallo, il bambino non gradisce, basta ripigliare l'oggetto, ed invocare il metodo (di colorazione) con il parametro conservato (rosso). Facile.

Bene, vediamo cosa Cocoa mette a disposizione. Attraverso la classe NSUndoManager è disponibile una zona dove conservare la triade oggetto-metodo-parametri. Supponiamo di voler realizzare il meccanismo di Undo per la cancellazione di un elemento: devo riscrivere il metodo remElemAtIndex per memorizzare le informazioni richieste.

- (void) remElemAtIndex: (int) i
{
    // gestione dello undo per aggiunta/rimozione elementi
    [[[self undoManager] prepareWithInvocationTarget: self] addElem: [ elemArray objectAtIndex: i ] atIndex: i ];
    // eseguo la cancellazione
    [ elemArray removeObjectAtIndex: i ];
    [ ownerView setNeedsDisplay: YES ];
}

Ho aggiunto una riga, piuttosto densa. Per prima cosa, ricavo una istanza della classe NSUndoManager. Poiché oggetti di tipo Document o View già la possiedono, mi limito a recuperarne una già disponibile (la cosa è un po' più complicata del previsto, ma lo spiego dopo). Poi predispongo le cose: dico di preparare un blocco di Undo che si riferisce all'oggetto self (nel caso, il vettore theElements che contiene tutti gli elementi della CoverView), e che per rimediare ad una cancellazione di un elemento, occorre utilizzare il metodo addElem:atIndex: (in altre parole, un metodo per aggiungere un elemento), al quale fornire come parametri l'elemento stesso in via di cancellazione e la sua posizione all'interno del vettore. Se l'utente, dopo la cancellazione, cambia idea e sceglie la voce di menù Undo, Cocoa esegue per noi qualcosa di simile alla seguente istruzione:

[ theElements addElem: <elemento> atIndex: <indice, i> ];

riportando la situazione al punto di prima.

Non ho ancora descritto iL metodo addElem:atIndex:, ma non credo vi spaventerà; del resto, è simile al vecchio addElem:, che aggiungeva brutalmente l'elemento all'inizio del vettore:

- (void) addElem: (CCE_BasicForm *) elem atIndex: (int) i
{
    // gestione dello undo per aggiunta/rimozione elementi
    [[[self undoManager] prepareWithInvocationTarget:self] remElemAtIndex: i ];
    // inserisco davanti a tutto
    [ elemArray insertObject: elem atIndex:i];
    [ ownerView setNeedsDisplay: YES ];
}

Utilizzare il vecchio metodo, senza la possibilità di indicare la posizione di inserimento, era certamente possibile, ma avrebbe snaturato l'ordine di rappresentazione degli elementi, provocando una non completa uguaglianza tra lo stato previsto e quello ottenuto.

Qui si trova anche l'altra faccia del meccanismo di Undo, il Redo. Il nostro indeciso utente può alla fine decidere che in effetti l'elemento va veramente eliminato dalla faccia della terra; piuttosto che cancellarlo un'altra volta, può utilizzare il comando di menu Redo o Ripristina (anche questo diventato indispensabile nelle moderne applicazioni). In effetti, il contrario di aggiungere un elemento, è di rimuoverlo; per farlo, si usa ancora il metodo remElemAtIndex. Ma come fa Cocoa a capire che questa volta non si tratta di una operazione di Undo ma di Redo? Per capirlo, bisogna parlare di pile (intendendo ammassi di cose, dove le cose si buttano una sopra l'altra, e si tolgono a partire dall'ultima che è stata aggiunta).

Pile di infiniti ripensamenti

Le moderne applicazioni hanno multipli livelli di Undo e di Redo, ovvero, è possibile annullare una sequenza di operazioni, o ripristinare tutte o sola alcune delle operazioni annullate. Per capire come funziona questo fare e disfare a mo' di tela di Penelope, faccio il seguente modello concettuale. Ogni operazione consiste in una tripletta di informazioni: l'indicazione di un oggetto, di un metodo da eseguire su questo oggetto, e dai parametri del metodo. Ogni operazione possiede (idealmente) una operazione complementare, tale per cui eseguendo in successione una operazione ela sua complementare, non si hanno modifiche allo stato generale dell'applicazione.

Un'applicazione con interfaccia utente possiede il famoso loop degli eventi, ovvero ogni applicazione che deve interagire con un utente generalmente è lì che fa nulla, aspettando qualche comando da parte dell'utente. Come il comando arriva, sono eseguite una serie di operazioni (che producono determinati risultati), che terminano con l'applicazione di nuovo in attesa di comandi da parte dell'utente. Un ciclo (o loop) degli eventi è ciò che accade tra un comando ed il successivo. Man mano che l'utente gioca con una applicazione, esegue dei comandi che si traducono in una serie di operazioni. Alcune di queste operazioni sono reversibili, e quindi un buon programmatore ha inserito istruzioni del tipo prepareWithInvocationTarget: con opportuna parametrizzazione. Cocoa mette via ordinatamente le corrispondenti triplette oggetto-metodo-parametri, inserendole sopra una pila. Questa è la pila degli Undo, e ad ogni ciclo degli eventi alla pila si aggiungono un po' di triplette. C'è anche un'altra pila, la pila dei Redo, che al momento è vuota.

Arriva ciò che tutti stiamo aspettando, ovvero l'utente che esegue un Undo. Allora il programma piglia il blocco di triplette che si trova in cima alla pila, corrispondenti alle operazioni eseguite nell'ultimo ciclo degli eventi, ed esegue le operazioni indicate. Se il programmatore non ha fatto sciocchezze, la sequenza delle istruzioni dovrebbe riportare l'applicazione nello stato precedente all'ultimo ciclo degli eventi, realizzando in tutto e per tutto un comando di Undo.

Ora, eseguendo le operazioni descritte dalle triplette, Cocoa può imbattersi (e generalmente lo fa) in istruzioni del tipo prepareWithInvocationTarget:. Ma queste istruzioni specificano operazioni che dovrebbero essere eseguite per riportare l'applicazione in uno stato prima dell'operazione di Undo: Cocoa piglia quindi il blocco delle triplette raccolte e le inserisce all'interno della pila dei Redo. In pratica, in seguito ad un comando di Undo, Cocoa piglia le triplette dalla pila degli Undo, le esegue, riceve nuove triplette (che se il programmatore è onesto, derivano dalle operazioni complementari di quelle appena eseguite) e le inserisce nella pila dei Redo.

Cosa succede se adesso l'utente comanda Redo? Cocoa piglia le triplette dalla pila dei Redo, ed esegue le operazioni indicate. Così facendo, si imbatte in nuove triplette, da conservare nella pila degli Undo (ancora una volta, se il programmatore è stato onesto, dovrebbe ritrovare tali e quali le triplette che aveva eliminato dalla pila degli Undo all'ultima esecuzione del comando corrispondente).

C'è inoltre da tenere presente che le pile non contengono solo un blocco di triplette alla volta. Cocoa fornisce di default una pila infinita per Undo e Redo: si può teoricamente conservare nella pila degli Undo (ed anche in quella dei Redo) un numero infinito di blocchi di triplette, in modo da tornare indefinitamente sui propri passi. In pratica la grandezza delle pile è limitata dalla memoria disponibile ma soprattutto dalla natura delle applicazioni, per le quali ha senso mantenere un numero sensato di blocchi sulla pila.

Undo nella pratica

Le operazioni che al momento considero reversibili non sono poi tantissime: aggiunta e cancellazione di un elemento; spostamento di un elemento; ridimensionamento di un elemento. Ho già descritto come gestire la cancellazione e il ripristino di un elemento. C'è una piccola cosa da notare: il codice effettivo di addElem: è il seguente:

- (void) addElem: (CCE_BasicForm *) elem atIndex: (int) i
{
    // gestione dello undo per aggiunta/rimozione elementi
    [[self undoManager] setActionName: @"ElemGroup -> addElem"];
    [[[self undoManager] prepareWithInvocationTarget:self] remElemAtIndex: i ];
    // inserisco davanti a tutto
    [ elemArray insertObject: elem atIndex:i];
    [ ownerView setNeedsDisplay: YES ];
}

Ho aggiunto una istruzione (non guardate la stringa, è per il mio debugging) per completare acconciamente la voce di menu. Ad esempio, mentre sto scrivendo questo testo con TextEdit, la voce di menù è in effetti Annulla Inserimento piuttosto che un semplice Annulla, proprio per evidenziare che l'operazione che sarebbe annullata è l'inserimento di testo. Se impartissi il comando di Annulla, il comando di Ripristino diverrebbe attivo, ma con la dicitura Ripristina Inserimento. Le stringhe "Annulla" e "Ripristina" sono messe da Cocoa, con il metodo setActionName: il programmatore le completa.

Per la gestione di Undo/Redo sugli spostamenti, occorre modificare i metodi moveMeIfSelectBy:

- (void)
moveMeIfSelectBy: (NSPoint) distance
{
    if ( (! cceIsLocked) && cceIsSelected )
    {
        // gestione dello undo del movimento
        [[self undoManager] setActionName: @"BasicForm -> moveMeIfSelectBy"];
        [[[self undoManager] prepareWithInvocationTarget: [ownerView theElements]]
            moveElem: self by: NSMakePoint(-distance.x, -distance.y)];
        // effettuo il movimento vero e proprio
        [ self setElemLimit: NSOffsetRect(elemLimit, distance.x, distance.y)];
        [ self buildMeWithStartPt: NSMakePoint(elemLimit.origin.x, elemLimit.origin.y)
                andPt: NSMakePoint( elemLimit.origin.x + elemLimit.size.width,
                                    elemLimit.origin.y + elemLimit.size.height) ];
    }
}

Ogni realizzazione di questo metodo richiede le due istruzioni per la gestione dello Undo, una per impostare la stringa, l'altra per rimediare allo spostamento. Ho costruito un nuovo metodo moveElem:by:, che riceve come parametro lo spostamento opposto a quello richiesto.

Ugualmente, all'interno del metodo handleResize:..., prima di ogni altra operazione, ho aggiunto le due istruzioni:

        // mi preparo lo undo
    [[self undoManager] setActionName: @"BasicForm -> handleResize"];
    [[[self undoManager] prepareWithInvocationTarget: [ownerView theElements]]
        resizeElem: self withStartPt: NSMakePoint(elemLimit.origin.x, elemLimit.origin.y)
            andPt: NSMakePoint( elemLimit.origin.x + elemLimit.size.width,
                                elemLimit.origin.y + elemLimit.size.height)];

In caso di Undo, verrà eseguito il (nuovo) metodo resizeElem:withStartPt:andPt:, che ha ricevuto come parametri le dimensioni correnti dell'oggetto, prima del ridimensionamento.

Ci sono due avvertenze: la prima è che, come al solito, la classe CCE_Line, costruendo le linee in modo diverso da quanto accade con i rettangoli ed i cerchi, ha una versione leggermente diversa di queste istruzioni (nulla di trascendente, tuttavia). La seconda avvertenza è in realtà la spiegazione dei nuovi metodi della classe CCE_ElemGroup:

- (void)
moveElem: (CCE_BasicForm*) elem by: (NSPoint) distance
{
    [ elem moveMeBy: distance ];    
}

- (void)
resizeElem: (CCE_BasicForm*) elem withStartPt: (NSPoint) stPt andPt: (NSPoint) endPt
{
    [ elem resizeMeWith: stPt andPt: endPt ];
}

La domanda che mi pongo (precedendo o voi miei venticinque lettori) riguarda il motivo per cui ho voluto usare theElements come target delle operazioni di Undo, piuttosto che direttamente gli oggetti coinvolti. Mi spiego meglio: perché il codice seguente non funziona come voglio?

- (void)
moveMeIfSelectBy: (NSPoint) distance
{
    if ( (! cceIsLocked) && cceIsSelected )
    {
        // gestione dello undo del movimento
        [[self undoManager] setActionName: @"BasicForm -> moveMeIfSelectBy"];
        [[[self undoManager] prepareWithInvocationTarget: self]
            moveMeBy: NSMakePoint(-distance.x, -distance.y)];
        // effettuo il movimento vero e proprio
        [ self setElemLimit: NSOffsetRect(elemLimit, distance.x, distance.y)];
        [ self buildMeWithStartPt: NSMakePoint(elemLimit.origin.x, elemLimit.origin.y)
                andPt: NSMakePoint( elemLimit.origin.x + elemLimit.size.width,
                                    elemLimit.origin.y + elemLimit.size.height) ];
    }
}

Questa seconda versione è certamente più efficiente della prima, in quanto evita un passaggio. Però mi rende confusa la gestione multi-livello degli Undo e dei Redo, per cui talvolta rimangono nelle pile degli Undo e dei Redo dei blocchi di operazioni che sembrano non avere alcun effetto. Confesso che la situazione mi è poco chiara, e ho già speso abbastanza tempo cercando di capire senza concludere nulla. Riferendo invece ogni operazione di Undo all'oggetto theElements, le cose cominciano a funzionare.

Ho un ulteriore paio di commenti sul metodo resizeMeWith; non è altro che buildMeWithStartPt con due istruzioni per la gestione dello Undo in testa. Ho preferito un metodo separato per il ridimensionamento dove esplicitare le due istruzioni per lo Undo, piuttosto che inserirle direttamente all'interno di buildMeWithStartPt. Il fatto è che il metodo buildMeWithStartPt è chiamato frequentemente per svolgere operazioni continue (spostamenti e ridimensionamenti), e tutti questi messaggi sarebbero stati inutili (tranne l'ultimo) ed anzi avrebbero riempito le pile. In secondo luogo, mi rendo conto che le operazioni di Undo sono chiamate due volte con la stessa parametrizzazione. La prima volta all'interno del metodo handleResize:..., e la seconda qui. Il motivo è che questo stesso metodo è ovviamente chiamato anche in caso di Redo (senza passare per handleResize:...); senza la coppia in handleResize: perdo lo Undo, senza questa coppia perdo il Redo.

- (void)
resizeMeWith: (NSPoint) stPt andPt: (NSPoint) endPt
{
    // gestione dello undo del movimento
    [[self undoManager] setActionName: @"BasicForm -> resizeMeWith"];
    [[[self undoManager] prepareWithInvocationTarget: [ownerView theElements]]
        resizeElem: self withStartPt: NSMakePoint(elemLimit.origin.x, elemLimit.origin.y)
            andPt: NSMakePoint( elemLimit.origin.x + elemLimit.size.width,
                                elemLimit.origin.y + elemLimit.size.height)];
    // metto a posto il rettangolo che lo contiene
    [self buildMeWithStartPt: stPt andPt: endPt ];
}

Tante piccole cose

Ci sono tante altre piccole cose per completare la descrizione delle modifiche al codice.

Ho aggiunto due variabili d'istanza (e metodi accessor) alla classe CCE_BasicForm. La prima è un riferimento alla CoverView in cui l'elemento grafico è contenuto. L'uso di questa variabile comporta alcune operazioni in più in sede di costruzione dell'oggetto, che si ripagano in un accesso più veloce alla CoverView quando si rende necessario. Ad esempio, il metodo undoManager, che serve a recuperare l'istanza condivisa della classe NSUndoManager per la gestione di Undo e Redo si scrive facilmente:

- (NSUndoManager *)undoManager
{
    return [[[ownerView winCtl] document] undoManager];
}

Per lo stesso motivo sono cambiati i metodi come handleCreation: che in precedenza richiedevano la view di riferimento come parametro.

L'altra variabile d'istanza è un numero intero cui assegno un valore crescente per ogni elemento; mi serve come identificatore dell'oggetto quando faccio del debugging (ad esempio, so che la linea di codice 1 è quella disegnata per prima, eccetera). Tutto ciò comporta una modifica a tutte le funzioni printMeWithLevel.

Ho spostato l'inviocazione al metodo setNeedsDisplay il più in basso possibile: in precedenza era la CoverView che forzava il ridisegno della vista perché presumeva che fosse qualcosa nel contenuto della vista. Per quanto possibile, ho spostato questa istruzione all'interno degli elementi, essendo loro stessi i migliori conoscitori della necessità o meno di un ridisegno. In particolare ciò ha richiesto una nuova versione del metodo accessor setCceIsSelected, che è diventato:

- (void)setCceIsSelected:(BOOL)newCceIsSelected {
    if (cceIsSelected != newCceIsSelected) {
        cceIsSelected = newCceIsSelected;
        [ [ self ownerView ] setNeedsDisplay: YES ];
    }
}

Se lo stato di selezionato di un elemento è stato modificato, certo ci sarà da ridisegnare qualcosa (gli handle, ad esempio).

Ho cambiato il comportamento della palette che contiene gli strumenti per il disegno della copertina. La versione precedente manteneva selezionato l'elemento anche dopo il completamento del tracciamento dell'elemento. Ho preferito fare in modo che, al termine della creazione di un nuovo elemento, lo strumento selezionato tornasse ad essere la freccia. Allo scopo, c'è un metodo della classe CoverPalWinCtl dall'affascinante nome di resetTool:

- (void)
resetTool
{
    [toolMatrix selectCellWithTag: 0] ;
     currCCE = nil ;
}

Il metodo è invocato da mouseDown: della classe CoverView al termine della creazione di un elemento.

Tutte le istruzioni di disegno della base della copertina, prima raccolte all'interno del metodo initWithFrame di CoverView sono ora raccolte in un metodo a parte. Saranno quanto prima sostituite da qualcos'altro.

Infine, per evitare troppa confusione nella selezione e nello spostamento degli oggetti, all'interno del metodo handleMoveAll di CoverView verifico se il nuovo punto del mouse è realmente nuovo, cioé se il puntatore del mouse si è spostato. Solo in tal caso scateno la proceudra di spostamento degli elementi selezionati.

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