2011年6月4日土曜日

Cocoa NSTreeControllerとNSOutlineView・まとめ



NSTreeControllerを使ってNSOutlineViewにファイルシステムを表示するサンプルが、ほぼ目的の動作を確認できるところまでできた。ここらでまとめをしておく。

後々の自分のための備忘録なので、特にすぐわからなくなるbindingあたりも画像付きで記録をのこしておくことにする。

プロジェクト
プロジェクトのファイル構成はこうなっている。


上からFileSystemItemはファイルシステムを表現するモデルクラス。具体的には1つのファイルのフルパス、表示用タイトル、親ディレクトリ、自分の子ディレクトリを所持している。
デベロッパドキュメント「Introduction to Outline Views」からのいただき。

ヘッダ。
#import 


@interface FileSystemItem : NSObject {
    NSString *fullPath;
    NSString *displayName;
    FileSystemItem *parent;
    NSMutableArray *children;
    
}
@property (readwrite, copy) NSString* displayName;
@property (readwrite, copy) NSString* fullPath;

+ (FileSystemItem *)rootItem;
- (id)initWithPath:(NSString *)path parent:(FileSystemItem *)parentItem; 
- (NSArray *)children;
- (NSInteger)numberOfChildren;// Returns -1 for leaf nodes

- (FileSystemItem *)childAtIndex:(NSUInteger)n; // Invalid to call on leaf nodes
- (NSString *)relativePath;
- (NSInteger) childIndexAtFullpath:(NSString*)path;
- (FileSystemItem*)childItemAtFullPath:(NSString*)path;
- (BOOL)isLeaf;
- (id)copyWithZone:(NSZone *)zone;

@end

もともとは「)relativePath」と「parent」を組み合わせてfullPathを返す仕様だったが、所々の理由でfullPathそのものを保持するように変更してある。
childIndexAtFullpath:とchildItemAtFullPath:、copyWithZone、isLeafは自分で書き足している。

このオブジェクトの配列をNSTreeControllerに格納している。

MainMenu.xib
NSTreeControllerはnibからロードしている。サブクラス化はしていない。
NSTreeControllerの設定は.xibファイルでほぼ済ませてしまえるようで、アトリビュートの設定の中でFileSystemItemのメソッドを指定することができる。

.xib全体はこんな感じ。


Controllerはメインのコントローラーで、ヘッダはこうなっている。
#import 


@interface Controller : NSObject {
    IBOutlet NSView* view;
    IBOutlet NSTreeController *treeController;
    IBOutlet NSOutlineView* outlineView;
    IBOutlet NSButton* button;
    NSMutableArray* volumes;
@private
    
}
@property (readwrite,assign) NSMutableArray* volumes;
-(IBAction)buttoPushed:(id)sender;
-(IBAction)butoonPushed2:(id)sender;
@end

NSMutableArray* volumes;がFileSystemItemを格納する配列。この配列をNSTreeControllerのContentArrayにbindingしている。
bindingなどはこうなった。



NSOutlineViewのValueもこの配列にbindingしている。


ModelKeyPathは空白にしておくとFileSystemItemのオブジェクトが渡ってくるはずなのだが、それが今回うまくいかず、「self」を入れたらOKだった。ここでFileSystemItemのオブジェクトそのものが渡ってくるようにしておけば、NSOutlineViewにカスタムセルを乗せてアイコンなどを表示することができるようになる。

NSOutlineView
NSOutlineViewも全くカスタマイズしていない。表示用のカスタムセルを載せているくらい。カスタムセルの設定はControllerのawakeFromNibで以下のようにしている。
  NSTableColumn* tableColumn;
    IconedCell*  iconedCell;
    tableColumn = [outlineView outlineTableColumn];
    iconedCell = [[[IconedCell alloc] init] autorelease];
    [tableColumn setDataCell:iconedCell];

カスタムセルである「IconedCell」は確か木下誠氏のサンプルからいただいたもの。ほとんど描画だけである。

#import "IconedCell.h"
#import "FileSystemItem.h"


@implementation IconedCell


- (id)init
{
    self = [super init];
    if (self) {
        // Initialization code here.
        //[self setInteriorBackgroundStyle:NSBackgroundStyleDark];
    }
    
    return self;
}

- (void)dealloc
{
    [super dealloc];
}
static int ICON_SIZE_WIDTH = 16;
static int ICON_SIZE_HEIGHT = 16;
static int MARGIN_X = 3;



- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView*)controlView
{
    NSString* path;
    NSRect pathRect;
    
    NSImage* iconImage;
    NSSize iconSize;
    NSPoint iconPoint;
    FileSystemItem *item=[self objectValue];
    
    // Draw Image
  
    iconImage=[[NSWorkspace sharedWorkspace] iconForFile:[item fullPath]];
    iconSize = NSZeroSize;
    iconPoint.x = cellFrame.origin.x;
    iconPoint.y = cellFrame.origin.y;
    
    if(iconImage) {
        iconSize.width = ICON_SIZE_WIDTH;
        iconSize.height = ICON_SIZE_HEIGHT;
        iconPoint.x += MARGIN_X;
        
        if([controlView isFlipped]) {
            iconPoint.y += iconSize.height;
        }
        
        [iconImage setSize:iconSize];
        [iconImage compositeToPoint:iconPoint operation:NSCompositeSourceOver];
    }
    
    // Draw text
    path = [item displayName];
    pathRect.origin.x = cellFrame.origin.x + MARGIN_X;
    if(iconSize.width > 0) {
        pathRect.origin.x += iconSize.width + MARGIN_X;
    }
    pathRect.origin.y = cellFrame.origin.y;
    pathRect.size.width = cellFrame.size.width - (pathRect.origin.x - cellFrame.origin.x);
    pathRect.size.height = cellFrame.size.height;
    
    if(path) {
        [path drawAtPoint:pathRect.origin withAttributes:nil];
    }
}
@end

iconImageをモデル側に持たせず、カスタムセル中で iconImage=[[NSWorkspace sharedWorkspace] iconForFile:[item fullPath]];と取得して使い捨てるようにしてみた。

datasourceもdelegateも一切必要がなく、やはりNSTreeControllerと組み合わせた方が使いやすいようだ。

Controller
コントローラーの仕事は大きく分けて3つ。

1,起動時点でマウントされているVolumeの取得とセット
これはこんな感じ。
-(void)setVolume{
    NSArray* mountedVols=[[NSFileManager defaultManager] mountedVolumeURLsIncludingResourceValuesForKeys:nil options:NSVolumeEnumerationSkipHiddenVolumes];
    NSMutableArray *roots=[NSMutableArray array];
    if ([mountedVols count] > 0){
  for (NSURL *element in mountedVols){
            FileSystemItem* item,*parent;
            if([[element path ]isEqualToString:@"/"]){
                item=[[FileSystemItem alloc] initWithPath:[element path] parent:nil];
            }
            else{
                parent=[[FileSystemItem alloc]initWithPath:[[element path] stringByDeletingLastPathComponent] parent:nil];
                item=[[FileSystemItem alloc] initWithPath:[element path] parent:parent];
                [parent release];
                
            }
            [roots addObject:item];
            [item release];
            
        }
  [self setVolumes:roots]; 
 }    
}

NSTreeControllerのContentになる配列にaddObjectすると、そのアイテムはいわゆるルートアイテムになる。アイテムであるモデルクラスでchildrenを取得するようにしておけば、ルートアイテムを追加するだけで木構造をコントロールしてもらえる。便利。モデルクラスでchildrenを管理できない場合はNSTreeNodeを使えばいいようだ。近いうちに「よく使うディレクトリのBookMark」を表示するNSOutlineViewを作ってみるつもりなので、その時はきっとNSTreeNodeを研究することになるだろう。

2、リムーバブルメディアのマウントとアンマウントの処理
マウントとアンマウントはNotificationで処理する。
awakeFromNibで
[[[NSWorkspace sharedWorkspace] notificationCenter] addObserver:self selector:@selector(didMount:) name:NSWorkspaceDidMountNotification object:nil];
[[[NSWorkspace sharedWorkspace] notificationCenter] addObserver:self selector:@selector(didUnmount:) name:NSWorkspaceDidUnmountNotification object:nil];
とaddObserverしておき、
-(void)didMount:(id)sender{
    NSLog(@"Mount! %@",sender);
    NSDictionary* info=[sender userInfo];
    NSURL* url=[info objectForKey:NSWorkspaceVolumeURLKey];
    FileSystemItem* parent=[[FileSystemItem alloc]initWithPath:[[url path] stringByDeletingLastPathComponent] parent:nil];
    FileSystemItem* item=[[FileSystemItem alloc] initWithPath:[url path] parent:parent];
    [parent release];
    [volumes addObject:item];
    [item release];
    [treeController rearrangeObjects];
  
    
}
-(void)didUnmount:(id)sender{
    NSLog(@"UnMount! %@",sender);
    NSDictionary* info=[sender userInfo];
    NSURL* url=[info objectForKey:NSWorkspaceVolumeURLKey];
    NSUInteger deleteNo;
    for (NSInteger i=0; i<[volumes count]; i++) {
        FileSystemItem* item=[volumes objectAtIndex:i];
        if ([[item fullPath] isEqualToString:[url path]]) {
           deleteNo=i;
        }
    }
    [volumes removeObjectAtIndex:deleteNo];
    [treeController rearrangeObjects];

}
もう少しスマートでクールなコードを書けるようになりたいものだが、確実に動く、自分で理解しやすいことを第一にしているのでこんなものだろう。 3,NSIndexPathを作る 特定のディレクトリを選択状態にしてNSOutlineViewを展開したことがある。そのためにはNSTreeControllerを使う場合はNSIndexPathの操作が不可避である。 デベロッパドキュメントのNSIndexPathのリファレンスで使われている概念図。
NSOutlineViewの特定の行を一撃で選択・展開できるので非常に便利。自分でNSIndexPathを作成する場合、例えば「@"/Applications/Chess.app/Contents"」というパスをNSIndexPathで表現するためには、ルートアイテムの「/」直下のディレクトリ群で「Applications」は何番目のindexか、さらに「Chess.app」は「/Applications」直下のディレクトリで何番目のindexか、というふうに順番に調べていかないといけない。 ルートアイテムが「/」だけなら話は簡単なのだが、リムーバブルメディアの場合は「/Volumes」が必ず頭につくことになるのでやっかいだ。ルートアイテムだからNSIndexPathの1段目にindexが入っているものの、「/」「Volumes」「うんたら」とpathComponent上では3段目まで使っていることになる。 そこで今回は、まず頭に「/Volumes」が付いていたら
-(FileSystemItem*)makeRootNodeItem:(NSString*)path{
    NSArray* pathArray=[path pathComponents];
    NSMutableString* pathString=[NSMutableString string];
    [pathString appendString:[pathArray objectAtIndex:0]];
    
    [pathString appendString:[pathArray objectAtIndex:1]];
    [pathString appendString:@"/"];
    FileSystemItem* parent=[[FileSystemItem alloc]initWithPath:pathString parent:nil];
    [pathString appendString:[pathArray objectAtIndex:2]];
    FileSystemItem* item=[[FileSystemItem alloc] initWithPath:pathString  parent:parent];
    [parent release];

    return [item autorelease];
    
}
というふうにFileSystemItemにしてしまうことにした。すごく冗長なのは自分でも目をつぶっている。 特定のパスをNSIndexPathに変換してNSTreeControllerのSelectionをセットするメソッドは次のようになる。
-(void)setSelectionByFullpath:(NSString*)path{
   
    NSInteger counter;
    FileSystemItem* item;
    
    BOOL isDir, valid;
    valid = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir] ;
    if (!isDir || !valid) {
        path=NSHomeDirectory();
    }
     NSArray *pathArray=[path pathComponents];   
    if ([[pathArray objectAtIndex:1] isEqualToString:@"Volumes"]) {
        item=[self makeRootNodeItem:path]; 
        counter=3;
    }
    else{
        item=[[FileSystemItem alloc]initWithPath:[pathArray objectAtIndex:0] parent:nil];
        counter=1;
    }
    NSIndexPath* indexPath;
    for(NSInteger i=0;i<[volumes count];i++){
        if ([[item fullPath] isEqualToString:[[volumes objectAtIndex:i] fullPath]]) {
            indexPath=[NSIndexPath indexPathWithIndex:i];
            
        }
    }
    FileSystemItem *newItem;
    NSInteger newIndex;
    NSMutableString* newPath=[[NSMutableString alloc]initWithString:[item fullPath]];
    for (NSInteger i=counter; i<[pathArray count]; i++) {
        if(i==1) [newPath appendString:[pathArray objectAtIndex:i]];
        else [newPath appendFormat:@"/%@",[pathArray objectAtIndex:i]];
       
        newItem=[item childItemAtFullPath:newPath];
        newIndex=[item childIndexAtFullpath:newPath];
        indexPath=[indexPath indexPathByAddingIndex:newIndex];
        item=newItem;
        
    }
  
    [treeController setSelectionIndexPath:indexPath];
   [item release];
    
}
以上で
-(IBAction)buttoPushed:(id)sender{

    NSString *path=[NSString stringWithString:@"/Applications/Chess.app/Contents"];
  
    [self setSelectionByFullpath:path];
}

といった処理が簡単にできるようになった。これでEse本体への組み込みのめどがついたようだ。

0 件のコメント:

コメントを投稿