MaCocoa 014

Capitolo 014 - L'inizio del Catalogo

Partendo dalla base del capitolo precedente, lo rifaccio cambiando un importante elemento dell'interfaccia grafica, per avvicinarmi di un passo all'idea del catalogatore di Volumi, come annunciato all'inizio di questo percorso. L'occasione è utilizzata anche per focalizzare alcuni concetti passati un po' in sordina nei capitoli precedenti.

Sorgenti: Faccio mio un esempio di Apple, /Developer/Examples/AppKit/OutlineView

Primo inserimento: 17 Dicembre 2001

L'interfaccia grafica

L'idea di partenza è sempre la stessa: abbiamo una finestra con un pulsante per selezionare un file o una directory, ed un elemento dell'interfaccia dove mostrare le informazioni relative. Ora, esiste un oggetto dell'interfaccia, NSOutlineView, che è molto adatto per presentare informazioni in maniera gerarchica. Infatti, NSOutlineView è ancora una volta un metodo per esplorare dati in maniera tabellare, ma questa volta è possibile raggruppare una serie di elementi sotto un unico genitore, e contrarre ed espandere questo genitore in modo che mostri o tenga nascosti i propri figli. Ho descritto in modo complicato la vista come lista del Finder...

Insomma, cerco di rappresentare all'interno della mia finestra una collezione di file e directory, dove le directory si possono espandere a mostrare i file in essa contenuti, e così via per le directory contenute nella directory, eccetera. Se la directory è un disco, praticamente è possibile visualizzare l'intero contenuto del disco.

L'utente deve essere in grado di indicare una serie di punti di partenza, siano essi file o directory, da inserire all'interno di un catalogo. Aggiungendo dischi su dischi, ecco che il catalogatore di Cd è pronto (si fa per dire).

figura 01

figura 01

L'interfaccia che disegno in IB è estremamente semplice; consiste nel solito pulsante che scatena le operazioni (l'ho solo allungato a coprire tutta la parte superiore della finestra), e nel campo NSOutlineView. Visto che ormai ho capito come gestire le colonne, ho pensato di ridurre la confusione lasciando solo una parte delle colonne definite nel capitolo precedente.

Ricorsione sulle directory

Torno adesso in XCode e mi occupo di un problema strettamente informatico. Nel capitolo precedente, il dialogo standard di apertura file permetteva di scegliere un file o una directory, e poi le classi sottostanti si limitavano a leggere le informazioni relative all'elemento selezionato. Adesso faccio un passo oltre. Se l'elemento selezionato è una directory, esploro il contenuto della directory stessa. Se uno degli elementi è esso stesso una directory, continuo ad esplorare i file contenuti, e via cosi, fino ad arrivare fino in fondo all'albero delle directory.

Per fare tutto ciò, occorre estendere l'oggetto LSFileInfo per aggiungere una serie di informazioni che permettano la costruzione dell'albero. Visto che parlo di oggetti e di classi, sono piacevolmente obbligato a costruire una sottoclasse della classe LSFileInfo, che chiamerò FileStruct. In questo modo sfrutto le caratteristiche premianti della OOP, ovvero la riusabilità e l'estendibilità dei costrutti pre-esistenti senza richiederne la completa disponibilità (va da sé che adesso ho la piena disponibilità della classe LSFileInfo, visto che scrivo tutto io, ma pensate ad un ambiente in cui si lavora in più d'uno...).

Bene, ecco il frammento del file FileStruct.h dove dichiaro la nuova classe:

#import "LSFileInfo.h"

@interface FileStruct : LSFileInfo {
    FileStruct     * parentDir ;
    NSMutableArray    * fileList ;
}

- (id) initTreeFromPath: (NSString*) fullPath parent: (FileStruct*) parentDir;
- (void) dealloc ;

Devo ovviamente includere la dichiarazione della superclasse, poi dichiaro la classe FileStruct come sottoclasse di LSFileInfo, ed aggiungo un paio di variabili d'istanza. La prima variabile serve a contenere un puntatore alla directory in cui l'elemento è contenuto. Da notare come posso utilizzare subito la dichiarazione di FileStruct, appena dichiarata. La seconda variabile serve a contenere, nel caso in cui l'oggetto sia esso stesso una directory, i puntatori ai file contenuti.

Ricordo che, pur non comparendo, sono comunque disponibili tutte le variabili d'istanza della superclasse LSFileInfo, anche se, da buon programmatore OO, vi accederò comunque tramite i metodi accessor standard.

Infine, sono dichiarati due metodi. Il primo è l'inizializzatore designato, e serve a costruire l'alberatura delle directory a partire da un path completo. Il secondo è la ridefinizione del metodo dealloc, in quanto nell'inizializzazione occorre allocare oggetti, che dealloc distrugge una volta inutili.

Tutta la difficoltà sta nel metodo initTreeFromPath: parent:. Allora lo scrivo qui e poi lo commento:

- (id) initTreeFromPath: (NSString*) fullPath parent: (FileStruct*) myParentDir ;
{
    NSFileManager *fileManager = [NSFileManager defaultManager];
    BOOL         isAdir, fileOK ;
    int            i ;

    [ super initWithPath: fullPath ] ;
    [ self setParentDir: myParentDir ] ;
    fileOK = [fileManager fileExistsAtPath:fullPath isDirectory: &isAdir];
    if (fileOK && isAdir)
    {
        NSArray *dirContent = [fileManager directoryContentsAtPath:fullPath];
        int numFile = [dirContent count];
        fileList = [[NSMutableArray alloc] initWithCapacity:numFile];
        for ( i = 0; i < numFile; i++)
        {
            [fileList addObject:[
                [FileStruct alloc]
                    initTreeFromPath:[ fullPath stringByAppendingPathComponent:
                        [dirContent objectAtIndex:i] ]
                    parent:self
                ]
            ];
        }
    }
    else
    {
            fileList = (id) -1 ;
    }
    return ( self );
}

La prima cosa che faccio è chiamare l'inizializzatore designato della superclasse, come è giusto. Già che ci sono, metto a posto la variabile d'istanza parentDir in cui inserisco appunto il parametro con cui il metodo è stato invocato. Ho già preventivamente definito un NSFileManager per accedere al file e capire se è una directory o meno.

Parentesi: mi scapperà più di qualche volta dire file anche dove dovrei dire directory. Non è sbagliato. In Unix qualsiasi cosa è un file. In particolare anche una directory è un file, di tipo speciale, ma un file. Chiusa parentesi.

Se il file non è una directory (attenti, sono nella parte else dell'if), assegno -1 alla variabile fileList. Questo è il solito sporco trucco dei programmatori, che utilizzano dei valori speciali all'interno delle variabili per indicare casi strani. Se infatti il file è un vero e proprio file, non si deve fare altro. Però, per indicare che l'oggetto è un file, invece di utilizzare un'altra variabile, o chiedere esplicitamente all'oggetto se è un file o una directory (da qualche parte LSFileInfo aveva una variabile che conteneva il tipo del file), metto -1 nel vettore destinato a contenere l'elenco dei file; una volta noto, si fa prima che con altri metodi...

La parte che ragiona con una directory è quella più interessante. Con il metodo directoryContentsAtPath: recupero in un vettore tutti i file contenuti nella directory. Conto subito quanti sono con il metodo count e costruisco un vettore (NSMutableArray) destinato a contenerli. Poi, iterando sul numero dei file, eseguo un'unica istruzione che in realtà consiste di una successione di messaggi.

Per prima cosa, recupero il nome del file: come

[dirContent objectAtIndex:i]

l'oggetto al posto i-esimo nel vettore.

Di questo file mi serve il path completo, che ottengo giustapponendo il path completo della directory che lo contiene con il nome stesso. Questo path

[ fullPath stringByAppendingPathComponent: <nome-del-file>]

è utilizzato come argomento del metodo di costruzione dell'oggetto FileStruct che alloco e quindi inizializzo:

[[FileStruct alloc] initTreeFromPath: <path-completo> parent:self]

ottenendo finalmente come risultato un oggetto della classe FileStruct che contiene tutte le informazioni necessarie (devo anche passare l'oggetto self come argomento).

L'oggetto FileStruct risultante è finalmente aggiunto al vettore fileList che avevo in precedenza definito.

[fileList addObject: <nuovo oggetto>]

La struttura ad albero in questo modo si costruisce da sé. Se infatti il messaggio initTreeFromPath: è inviato ad una directory, il metodo è ricorsivamente chiamato al momento della costruzione della directory stessa, per cui il procedimento si espande da solo fino a coprire tutti i file contenuti all'interno della directory di partenza ed in tutte le directory contenute.

NSOutlineView

Ora che sono in grado di costruire un'alberatura completa a partire da una directory, provo ad inserire il tutto all'interno di una NSOutlineView. Il concetto di base è lo stesso visto per la NSTableView del passato capitolo. L'oggetto NSOutlineView non contiene al suo interno i dati, ma delega tale operazione ad un altro oggetto, definito dall'utente, che deve fornire all'oggetto NSOutlineView un insieme minimo di servizi per il riempimento della vista.

Nel caso della tabella NSTableView, il discorso era piuttosto semplice, in quanto si trattava di definire due soli metodi; il primo per dire quanti elementi erano presenti nella tabella, il secondo per individuare, date riga e colonna della tabella, cosa doveva essere mostrato nella cella della tabella. Qui le cose sono un po' più complicate (ed infatti tra vicissitudini varie ci ho messo parecchio per venirne a capo, o meglio, per avere qualcosa che funziona... non sono tanto sicuro di aver capito tutto), anche a causa del fatto che il numero di righe non è predeterminato (gli elementi si possono espandere e contrarre).

I metodi appunto che una classe adatta a funzionare come sorgente di dati per una NSOutlineView sono i seguenti:

- (int) outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item;
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item ;
- (id) outlineView:(NSOutlineView *)outlineView child:(int)index ofItem:(id)item ;
- (id) outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item;

Il punto di partenza per capire il funzionamento del meccanismo è il parametro item. Questo argomento è l'oggetto vero e proprio che deve essere mostrato in una data riga della tabella. Infatti l'ultimo metodo sopra elencato è l'equivalente di quello utilizzato da NSTableView: la differenza sta nel fatto che lì era passato il numero della riga, qui invece, in assenza di un affidabile numero di riga, si dà il riferimento all'oggetto stesso.

Gli altri metodi partono sempre da questo item, e permettono di gestire con comodità il meccanismo di espansione, contrazione, eccetera. Infatti, con il metodo outlineView:numberOfChildrenOfItem: l'oggetto NSOutlineView è in grado di capire quanti sono i figli dell'elemento, e quindi, utilizzando il metodo outlineView:child:ofItem: è in grado di esaminarli uno ad uno, e di chiedere direttamente a loro cosa visualizzare in occorrenza di una data colonna (con l'ultimo metodo sopra citato). Per la gestione dell'espansione/contrazione usa il metodo outlineView:isItemExpandable:, attraverso il quale si può capire se l'elemento può essere espanso o meno. Nel primo caso, visualizza a lato dell'elemento il classico triangolino dall'orientamento variabile, a seconda se l'elemento è aperto e mostra i figli, oppure è ancora chiuso.

Ora, tutto questo meccanismo è chiaro ed anche piuttosto semplice, ma rimane il problema di far partire tutto il meccanismo (che è appunto il punto dove mi sono bloccato a lungo). L'esempio fornito da Apple funziona, ma sorvola allegramente sulla questione. A differenza dell'esempio, la mia applicazione ha due problemi aggiuntivi. Questi fatti mi complicano la faccenda

e ne sono venuto fuori con la seguente ipotesi di lavoro (non necessariamente vera, ma visto che funziona...).

NSOutlineView, per cominciare, non sa che pesci pigliare, nel senso che non conosce quanti e quali item sono presenti al suo interno. Invia quindi il messaggio corrispondente al metodo outlineView:numberOfChildrenOfItem:, dove però l'argomento item non è definito, o meglio, è nullo. L'oggetto sorgente di dati interpreta il tutto come la richiesta di quanti elementi di primo livello (i miei punti di partenza) sono contenuti al suo interno. Saputo questo, passa ordinatamente a chiedere gli elementi, individuandoli con il metodo outlineView:child:ofItem:; ancora una volta, l'argomento item è nullo, per indicare che sta cercando gli elementi di primo livello. A questo punto, la prima visualizzazione della NSOutlineView è completata mostrando gli elementi delle varie colonne...

La gestione dell'espansione e contrazione dei vari elementi procede di qui con facilità, visto che adesso è possibile dare dei valori sensati all'argomento item. È la stessa NSOutlineView a tenere traccia di quali elementi sono espansi, quali contratti, in modo da sgravare il resto del software da questo compito noioso. Quindi, quando è richiesto un'aggiornamento della finestra, NSOutlineView ricomincia daccapo (dapprima con gli elementi di primo livello, che ci sono sempre) e poi scendendo man mano sui livelli inferiori, in base alla condizioni di espanso/contratto dei vari elementi.

Il codice

Questo discorso si riflette su come sono realizzato i metodi sopra descritti nel mio caso. Ho quindi definito una classe LSDataSource (per pigrizia mentale ho utilizzato lo stesso nome della classe del capitolo precedente, ma sono classi del tutto diverse...), che ho collegato all'outlet dataSource della NSOutlineView direttamente da IB. Il file LSDataSource.h risulta qualcosa del genere:

@interface LSDataSource : NSObject
{
    NSMutableArray    *startPoint ;
}
- (id) init ;
- (void) dealloc ;

- (NSMutableArray*)startPoint;
- (void)setStartPoint:(NSMutableArray*)newStartPoint;

- (void) addFileEntry: (id) newEntry ;
    
- (int)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item;
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item ;
- (id)outlineView:(NSOutlineView *)outlineView child:(int)index ofItem:(id)item ;
- (id)outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item;
- (BOOL)outlineView:(NSOutlineView *)outlineView shouldEditTableColumn:(NSTableColumn *)tableColumn item:(id)item ;

@end

C'è una variabile d'istanza startPoint (un vettore) utilizzato per contenere gli oggetti di primo livello; un metodo di init e uno di dealloc, ai soli scopi della gestione della memoria necessaria al vettore startPoint, i metodi accessor per la variabile, un metodo per aggiungere un elemento di primo livello, i quattro metodi che forniscono i dati alla NSOutlineView, ed un ultimo metodo di cui parlerò verso la fine del capitolo.

Lascio perdere la definizione (ovvia) dei primi cinque metodi, e mi concentro sui quattro cruciali.

// con questo metodo dico quanti figli ha l'item
- (int)outlineView:(NSOutlineView *)outlineView numberOfChildrenOfItem:(id)item
{
    if (item == nil)
        return( [ [self startPoint] count ]);
    return ( [item numOfFiles] ) ;
}

Il metodo si commenta da sé: se l'argomento item è nullo (si usa la parola chiave nil per indicare che si tratta di un puntatore o un oggetto nullo), NSOutlineView sta ragionando sugli elementi di primo livello, e quindi si risponde con il numero di elementi del vettore startPoint.

Se invece item è un oggetto effettivo, non può che trattarsi di un oggetto della classe FileStruct, al quale chiedo di dirmi quanti figli ha; allo scopo ho infatti aggiunto all'interno della classe FileStruct un nuovo metodo che aiuta l'incapsulamento dei dati:

- (int)numOfFiles
{
    id tmp = [self fileList];
    if (tmp == (id)(-1))
        return ( 0 );
    else return ( [tmp count] ) ;
}

Ancora una volta, è molto semplice: recupero l'elenco dei file contenuti. Se il valore è -1, il file con il quale stiamo lavorando è un file vero e proprio, e quindi non ha figli. Restituisco Zero come numero di figli. Se invece è una directory, basta contare quanti elementi sono contenuti nel vettore.

Il passo successivo è il metodo che restituisce i figli degli elementi:

// con questo metodo dico qual e' il figlio 'index-esimo' di item
- (id)outlineView:(NSOutlineView *)outlineView child:(int)index ofItem:(id)item
{
    if (item == nil)
        return( [ [self startPoint] objectAtIndex: index ]);
    return ([item getFileAtIndex:index]);
}

Se stiamo parlando dell'elemento nil, allora si ragiona sugli elementi di primo livello, e quindi si deve restituire l'elemento index-esimo del vettore startPoint. Se invece l'elemento è un file (una directory, a questo punto), ho definito un altro metodo di FileStruct per facilitarmi il compito, ma che fa essenzialmente la stessa cosa, ma su un altro vettore:

- (FileStruct *)getFileAtIndex:(int) elem
{
    return [[self fileList] objectAtIndex:elem];
}

Per capire se un elemento è espandibile, ho l'apposito metodo:

// con questo metodo dico se item e' espandibile
- (BOOL)outlineView:(NSOutlineView *)outlineView isItemExpandable:(id)item
{
    if (item == nil)
        return ( YES ) ;
    return ([item numOfFiles] != 0);
}

Se stiamo parlando dell'elemento nil, la risposta non ha importanza; dico di sì giusto pro forma. Altrimenti, nel caso di un elemento file o directory, dico che è espandibile se il numero di figli dell'elemento è diverso da Zero.

Rimane da dire cosa mostrare nelle varie colonne: quasi più semplice del corrispondente metodo del capitolo precedente:

// con questo metodo dico cosa deve mostrare item nella colonna indicata
- (id)outlineView:(NSOutlineView *)outlineView objectValueForTableColumn:(NSTableColumn *)tableColumn byItem:(id)item
{
    NSString * colId ;

    if (item == nil)
        return ( @"???" ) ;
    // recupero l'identificatore della colonna
    colId = [ tableColumn identifier] ;
    return ( [ item valueForKey: colId ] ) ;
}

Se l'elemento non esiste, restituisco una stringa a caso. Altrimenti, ricorro allo stesso giochetto dell'identificatore di colonna e valueForKey: già visto.

Deleghe

Rimane da discutere l'ultimo metodo della classe: outlineView:shouldEditTableColumn:item:, attraverso il quale si comunica alla NSOutlineView se il campo dell'elemento indicato dall'accoppiata item e NSTableColumn può essere editato oppure no. In effetti, potrei decidere che alcuni attributi del file possano essere modificati ed altri no. Per non complicarmi la vita, dico che non si può toccare nulla di quello mostrano dentro la tabella:

- (BOOL)outlineView:(NSOutlineView *)outlineView shouldEditTableColumn:(NSTableColumn *)tableColumn item:(id)item
{
    return NO;
}

La questione potrebbe chiudersi qui se non fosse che con questo metodo si introduce il concetto di classe delegata. Ci sono infatti alcuni oggetti che, per poter svolgere completamente le proprie funzioni, debbano delegare a qualche altro oggetto alcune funzioni, in quanto non sono in grado di svolgere da soli le operazioni. Decidere se una data cella della tabella possa essere editata o meno non è una questione facilmente risolvibile dalla tabella stessa (NSOutlineView è essenzialmente una classe per la realizzazione dell'interfaccia grafica,). Abbiamo cioè un caso speciale del paradigma Model-View-Controller, in cui un oggetto di tipo View richiede esplicitamente un oggetto di tipo Controller per la gestione di alcune funzioni. Si dice allora che l'oggetto View delega una serie di operazioni ad un oggetto delegato. Nel mio caso, dico che l'oggetto delegato (controller) della NSOutlineView è lo stesso oggetto che fa da sorgente di dati, ma avrei potuto decidere diversamente. Il fatto che il metodo delegato richiesto sia molto semplice mi ha fatto decidere per una economicità degli oggetti, e di attribuire all'oggetto della classe LSDataSource la doppia funzione. Il fatto che normalmente la sorgente di dati e l'oggetto delegato siano concettualmente diversi si può vedere in IB, in cui occorre assegnare esplicitamente, nel modo classico di tracciare un percorso tra i due oggetti tenendo premuto il tasto Control, un collegamento per l'outlet DataSource ed un collegamento per l'outlet Delegate.

Come si parte

Per chiudere, rimane solo da dire come si fa ad aggiungere un elemento di primo livello, ovvero cosa succede quando l'utente fa clic sull'unico pulsante dell'applicazione. Ecco il frammento di codice che riporta le poche modifiche del metodo lsGetFile:

if (result == NSOKButton) {
    NSArray *filesToOpen = [oPanel filenames];
    NSString *aFile = [filesToOpen objectAtIndex:0];
    FileStruct * fInfo = [[FileStruct alloc] initTreeFromPath: aFile parent: nil];

    [ dataSource addFileEntry: fInfo ];
    [ outlineView reloadData ];
}

In risposta ad una scelta dell'utente, si recupera il nome del file come percorso completo, si costruisce un oggetto della classe FileStruct, lo si aggiunge alla base di dati e si dice all'oggetto NSOutlineView che sono cambiati i dati, e che bisogna quindi rinfrescare la finestra.

Da notare come la costruzione dell'oggetto fInfo scateni anche la creazione di tutta l'alberatura delle directory e la raccolta delle informazioni di tutti i file contenuti.

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