[iOS] 코어데이터를 활용한 간단 메모장 – 다시 쓰기

작년인가 썼던 코어데이터를 사용해서 간단한 메모앱을 만드는 예제를 설명하는 글이 있었는데, 시간이 지나서 살펴보니 댓글도 좀 달릴 만큼 관심을 받았는데…. 지금에와서 읽어보니 글(글에서 소개하는 코드가)이 좀 엉망이라 부끄러운 관계로, 조금 더 간단히 써보고자 한다. 코어 데이터와 관련된 내용은 다루지 않고 (이 부분은 시간이 나면 다시 따로 정리하기로 하고) 코드에 대해서만 집중해보도록 한다. 이번 글의 목표는 중구난방이 아닌 보다 깔끔한 구현을 목표로 한다.

요건

만들고자 하는 앱이 다루는 범위는 이전과 동일하다. 새로 메모를 작성하고, 이를 저장한다. 앱은 시동시에 저장된 메모 내용을 불러와서 수정할 수 있다. 그외 ARC, 스토리보드를 사용하는 앱 기준으로 코드의 내용을 바꾼다. 또한 관련된 클래스의 디자인도 조금 더 간결하게 변경하고자 한다.

프로젝트는 Empty Application으로 시작해서 스토리보드를 하나 새로 추가하는 형태로 제작한다. 프로젝트 생성시 코어데이터 사용에 체크한다.

설계

이 앱은 간단히 3개의 클래스로 구성된다. 각각 앱 델리게이트, 리스트 뷰 컨트롤러, 디테일 뷰 컨트롤러로 이들은 다음과 같이 구성한다.

  • 앱 델리게이트 : 코어데이터에 필요한 ManagedObjectContext 객체를 제공해 준다. 그외 디폴트로 앱이 종료되는 시점에 자동으로 컨텍스트를 저장한다. (프로젝트 템플릿에 이미 있는 기능임)
  • 리스트 뷰 컨트롤러 : 테이블 뷰 컨트롤러로, 화면에 메모 목록을 표시한다.
  • 디테일 뷰 컨트롤러 : 데이터 필드 1개와 텍스트 뷰 1개로 이루어진 뷰. 선택된 메모의 상세 내용을 표시하고, 수정/작성한다.

또한 코어데이터 모델로는 Memo가 있고 이는 다음과 같은 속성들을 가지고 있다.

  • title (text)
  • content (text)
  • createdDate (date)
  • lastModifiedDate (date)

앱 델리게이트

앱 델리게이트는 별도로 손 볼 곳은 없다. 단, Empty Application으로 프로젝트를 만들면, 이 클래스에서 코드를 통해 앱의 화면을 구성하기 때문에 이후에 추가한 스토리보드가 표시되지 않는다. 따라서 -application:didFinishLaunchingWithOptions:의 내용을 다음과 같이 모두 제거하고 YES를 리턴하도록 변경한다.

-(BOOL)application:(UIAppliaction *)application didFinishLaunchingWithOptions:(NSDictionary *)options {
    return YES;
}

리스트 뷰 컨트롤러

지난 번 글에서의 구현은 완전 중구난방의 디자인이었던 것을 조금 깔끔하게 변경하고자 한다. 리스트 뷰 컨트롤러는 다음과 같은 프로퍼티들이 필요하다.

  • context : 저장된 메모들을 제어할 수 있는 managed object context.
  • memoListArray : 저장된 메모들이 들어있는 배열 객체
  • indexedMemo : 신규로 작성하거나 수정할 때 그 대상이 되는 메모 객체

그리고 디테일 뷰에서 편집을 완료한 후에는 수정한 내용을 저장하고, 목록을 갱신해야 한다. 이는 상세 뷰에서 목록으로 돌올 때 -viewWillAppea를 쓰지 않고 디테일 뷰 컨트롤러의 델리게이트로 자신을 등록해서, 이를 처리하도록 한다. 따라서 델리게이트 프로토콜을 하나 정의해 줘야 한다.

디테일 뷰 컨트롤러

디테일 뷰 컨트롤러는 편집기의 역할을 한다. 편집을 취소하거나 확정하는 2개의 경우가 있을 수 있는데, 이 때 편집한 내용을 잃지 않고 저장하기 위해서는 저장 작업은 리스트 뷰 컨트롤러에게 위임할 것이다. 그외에는 특별한 부분이 없다.

구현

스토리보드

스토리보드를 새로 생성해서 테이블 뷰 컨트롤러 하나와, 일반 뷰 컨트롤러 하나를 끌어다 놓는다. 각각의 클래스 이름은 ANListViewControllerANDetailViewController가 된다. 리스트 뷰 컨트롤러를 editor > embed > navigation controller를 선택해 네비게이션 컨트롤러 내에 두고, 네비게이션 컨트롤러가 초기 화면이 되도록 initial scene 속성에 체크해준다.

리스트 뷰 컨트롤러의 테이블 뷰 셀에서 마우스 오른쪽 클릭&드래그로 디테일 뷰 컨트롤러로 연결하여 segue를 하나 만들어주고, 이 segue의 식별자를 “addMemoSegue”로 지정한다. 또, 신규 메모 작성을 위해 네비게이션 바 우측에 툴바 아이템을 하나 추가해서 여기서 디테일 뷰 컨트롤러로 이어지는 segue를 추가한 뒤, 이름을 “viewDetailSegue”로 지어준다. 테이블 뷰 셀의 재사용ID는 적당한 값을 준다.(이들 이름은 모두 적당한 값을 주면 되나, 코드상에서는 동일한 문자열을 입력해줘야 한다.)

ANListViewController.h

리스트 뷰 컨트롤러의 헤더는 별다른 프로퍼티를 선언할 게 없고(프로퍼티는 객체 외부에서 참조하려는 속성인데, 외부 객체가 액세스할 필요가 없다) 대신에 프로토콜을 하나 정의해준다. 그리고 인터페이스 선언시에는 자기 스스로가 이 프로토콜을 따르도록 한다.

#import <UIKit/UIKit.h>
#import "Memo.h"

// 델리게이트 프로토콜 정의
@protocol ANMemoEditorDelegate
@optional
-(void)editorDidFinishedEditing:(id)sender
@end

@interface ANListViewController : UITableViewController <ANMemoEditorDelegate>
// 테이블 뷰 컨트롤러 외부에서 참조할 프로퍼티가 없으므로, 모든 프로퍼티는 *.m 파일 내에서 선언함
@end

ANListViewController.m

예전 글의 구현에서 가장 지저분하게 돼 있던 점은 앱 델리게이트의 관리객체 컨텍스트를 찾아서 저장을 하거나 새 메모를 만들거나 하는 식으로 작업을 했는데, 그냥 깔끔하게 리스트 뷰 컨트롤러의 프로퍼티로 지정해버린다.

리스트 뷰 컨트롤러의 컨텍스트 프로퍼티에 앱 델리게이트의 관리 객체 컨텍스트를 대입시키려면 예전 같으면, -init…류의 초기화 메소드에서 처리하려고 했겠지만, 프로퍼티 자체는 “함수의 리턴값”이므로, 접근자 함수에서 이 일을 처리해주면 별도의 초기화 메소드가 필요없다. 같은 방식으로 메모 리스트 접근자 함수에서도 컨텍스트를 사용하여 리스트를 로딩해오는 내용을 넣어버렸다.

셀이나 버튼을 클릭해서 뷰를 전환하던 방식은 모두 segue를 통해 처리한다. 어디를 눌러서 화면이 전환되는지는 segue의 identifier 속성을 통해 알 수 있으므로 디테일 뷰 컨트롤러에서 사용할 메모를 지정해 줄 수 있다.

#import "ANListViewController.h"
#import "ANDetailViewController.h"
#import "ANAppDelegate.h"

@interface ANListViewController ()
//
@property (strong, nonatomic) NSManagedObjectContext *context;
@property (strong, nonatomic) NSArray *memoListArray;
@property (strong, nonatomic) Memo *indexedMemo;
@end

@implementation ANListViewController
@synthesize context=_context, memoListArray=_memoListArray, indexedMemo;

-(NSManagedObjectContext *)context
{
    // 앱 델리게이트의 컨텍스트를 참조하여 컨텍스트로 사용함
    if(!_context)
    {
        ANAppDelegate *appDelegate = [[UIApplication sharedApplication] delegate];
        _context = appDelegate.managedObjectContext;
    }
    return _context;
}

-(NSArray *)memoListArray
{
    // 메모목록의 배열은 참조할 때 마다 코어데이터로부터 신규 목록을 갱신하도록 함. 
    // 이 방식은 오버헤드를 크게 만들 소지는 있지만, 메모앱 수준에서는 큰 문제는 없음.
    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init];
    NSEntityDescription *anEntity = [NSEntityDescription entityForName:@"Memo" inManagedObjectContext:self.context];
    NSSortDescriptor *sd = [[NSSortDescriptor alloc] initWithKey:@"lastModifiedDate" ascending:NO];
    [fetchRequest setEntity:anEntity];
    [fetchRequest setSortDescriptors:@[sd]]; //
    _memoListArray = [self.context executeFetchRequest:fetchRequest error:nil];
    return _memoListArray;
}

-(void)editorDidFinishedEditing:(id)sender
{
    // 디테일뷰에서 편집/작성이 끝나면 저장하고 테이블 뷰를 갱신함
    [self.conext save:nil];
    [self.tableView reloadData];
}

#pragma mark - table view datasource
// 테이블 뷰 데이터 소스는 기존과 다를 부분이 특별히 없음.
-(NSInteger)numberOfSectionsInTableView:(UITableView*)tableView {return 1;}
-(NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section
{
    return [self.memoListArray count];
}
-(UITableViewCell*)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"MemoListCell";
    UITableViewCell *cell = [tableView dequeReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath];
    if(!cell) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault];
    }

    Memo *currentMemo= [self.memoListArray objectAtIndex:indexPath.row];
    cell.textLable.text = currentMemo.title;
    NSDateFormatter *df = [[NSDateFormatter alloc] init];
    [df setDateStyle:NSDateFormatterNoStyle];
    [df setTimeStyle:NSDateFormatterShortStyle];
    cell.detailTextLabel.text = [df stringFromDate:currentMemo.lastModifiedDate];

    return cell;
}

-(void)prepareForSegue:(UIStoryboardSegue *)segue
{
    if([segue.identifier isEqualToString:@"addMemoSegue"])
    {   // 신규 메모 추가시 준비 작업 - 새 메모 객체를 생성함
        ANDetailViewController *detailViewController = [segue destinationViewController];
        self.indexedMemo = [NSEntityDescription insertNewObjectForEntityName:@"Memo" inManagedObjectContext:self.context];
        detailViewController.currentMemo = self.indexedMemo;
        detailViewController.delegate = self;
    }

    if([segue.identifier isEqualToString:@"viewDetailSegue"])
    {
        // 기존 메모 보기/편집 시 준비작업 - 선택된 셀이 가리키는 메모를 전달함.
        ANDetailViewController *detailViewController = [segue destinationViewController];
        self.indexedMemo = [self.memoListArray objectAtIndex:[[self.tableView indexPathForSelectedRow] row]];
        detailViewController.currentMemo = self.indexedMemo;
        detailViewController.delegate = self;
    }
}

ANDetailViewController.h

디테일 뷰 컨트롤러는 리스트 뷰 컨트롤러에서 접근해야 할 프로퍼티가 2개 있다. 하나는 편집할 메모 객체, 다른 하나는 델리게이트(리스트 뷰 컨트롤러 자신이된다.) 이 두 개를 헤더 파일에서 선언해준다. 나머지 선언해야 할 프로퍼티와 메소드는 IB상에서 지정하면 된다. (구성은 이전 글의 것과 동일하다.)

뷰 컨트롤러의 뷰 내에 텍스트 필트 하나와 텍스트 뷰 하나를 추가하고 각각 IBOutlet으로 연결한다. 그리고 네비게이션 바 양쪽에 Cancel, Done 버튼을 추가해서 각각 탭했을 떄 호출될 액션 메소드를 하나씩 만들어 둔다. 다음은 소스이다.

//ANDetailViewController.h
#import <UIKit/UIKit.h>
#import "ANListViewController.h"
#import "Memo.h"

@interface ANDetailViewController : UIViewController
@property (weak, nonatomic) id <ANMemoEditorDelegate>delegate;
@property (strong, nonatomic) Memo *currentMemo;
@end

ANDetailViewController.m

//ANDetailViewController.m

이 클래스가 하는 일은 간단한데, 뷰가 표시되기 전에 segue 준비 동작에서 할당 받은 currentMemo 객체로부터 제목과, 본문의 내용을 가져와서 각각의 뷰에 표시해주기만 하면 된다. 편집을 완료하거나 취소하는 시점에서는 해당 메모 객체의 값을 변경해주고, 델리게이트(리스트 뷰)로 하여금 편집이 종료되었을 때의 처리(리스트 뷰에서는 컨텍스트를 저장하고, 리스트를 갱신함)하도록 지시만 해주면 된다.

뷰를 제거하고 리스트로 돌아가는 것은 네비게이션 컨트롤러의 -popViewController:를 호출하면 된다. (네비게이션 컨트롤러는 뷰 컨트롤러를 스택으로 관리하기 때문에 pop만 해주면 알아서 나머지 처리가 된다.)

#import "ANDetailViewController.h"

@interface ANDetailViewController ()
@property (weak) IBOutlet UITextField *titleField;
@property (weak) IBOutlet UITextView *contentView;
-(IBAction)doneTapped:(id)sender;
-(IBAction)cancelEdit:(id)sender;
@end

@implementation ANDetailViewController

-(void)viewWillAppear:(BOOL)animated
{
    self.titleField.text = self.currentMemo.title;
    self.contentView.text =  self.currentMemo.content;
}

-(IBAction)doneTapped:(id)sender
{
    self.currentMemo.title = self.titleField.text
    self.currentMemo.content = self.contentView.text;
    self.currentMemo.lastModifiedDate = [NSDate date];
    self.currentMemo.createdDate = (self.currentMemo.createdDate) ? self.currentMemo.createdDate : [NSDate date];

    [self.delegate editorDidFinishedEditing:self];
    [self.navigationController popViewControllerAnimated:YES];
}

-(IBAction)cancelEdit:(id)sender
{
    [self.navigationController popViewControllerAnimated:YES];
}

프로젝트 전체 파일은 여기에서 다운로드 받을 수 있습니다.