UIScrollView 컨텐츠 영역 화면 캡쳐하기

Bitmap image context(이전 포스팅 참조)를 활용한 화면 캡쳐를 해보자. 단순 화면 캡쳐가 아닌 UIScrollView내의 컨텐츠를 이미지로 캡쳐하여 저장한다.

테스트를 위해 화면 아래로 스크롤이 가능하도록 20개의 row를 가진 UITableView 하나를 만든다. UITableView는 UIScrollView를 상속하고 있어 별도의 UIScrollView를 만들지 않았다.(구현 모습은 아래 그림 참고)

테이블 row 를 클릭하면 UIScrollView영역, 즉 20개의 row가 담긴 이미지가 앨범에 저장되도록 한다.

test project

바로 코드를 통해 확인해보자.

// import QuartzCore
#import <QuartzCore/QuartzCore.h>

- (void)viewDidLoad{

    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    _tableView.delegate = self;
    _tableView.dataSource = self;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{

    // scroll이 생기도록 20개의 row를 생성
    return  20;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

    // 간단하게 row를 출력하는 UITableViewCell 생성
    UITableViewCell *cell = nil;
    cell = [tableView dequeueReusableCellWithIdentifier:@"test"];
    if(!cell){
        cell = [[UITableViewCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"test"];
    }

    cell.textLabel.text = [NSString stringWithFormat:@"%d", indexPath.row];
    return cell;
}

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{

     // capture 이미지 생성
    UIImage* captureImage = [self captureContentsInScrollView:indexPath.row];

    if(!captureImage) return;

    // 사진 앨범에 저장
    UIImageWriteToSavedPhotosAlbum(captureImage, nil, nil, nil);

}

-(UIImage*)captureContentsInScrollView:(int)selectRow{

    // 생성된 이미지를 저장할 변수
    UIImage *capture = nil;

    // UIScrollView의 기존 frame을 저장 (이 예제에선 UITableView가 UIScrollView의 역할)
    CGRect originTableViewFrame = _tableView.frame;

    // capture할 영역을 지정. UIScrollView의 컨텐츠 사이즈
    CGSize captureSize = CGSizeMake(_tableView.contentSize.width, _tableVie.contentSize.height);

    // bitmap graphic context 생성
    UIGraphicsBeginImageContextWithOptions(captureSize, YES, 0.0);

    // UIScrollView의 frame을 content 영역으로 변경
    _tableView.frame = CGRectMake(0, 0, captureSize.width, captureSize.height);

    // UIScrollView frame영역을 bitmap image context에 그림
    [_tableView.layer renderInContext:UIGraphicsGetCurrentContext()];

    //  bitmap graphic context로부터 이미지 획득
    capture = UIGraphicsGetImageFromCurrentImageContext();

    // UIScrollView의 frame을 원래대로 변경 (변경하지 않으면 스크롤이 안됨)
    _tableView.frame = originTableViewFrame;

     // bitmap image context 종료
     UIGraphicsEndImageContext();

    return capture;
}

Simulator로 실행하여 row를 선택하면 다음의 이미지가 앨범에 저장되는 것을 확인할 수 있다.

전체 캡쳐 결과

UIImageWriteToSavedPhotosAlbum()함수는  UIKit에서 지원하는 함수로 디바이스 앨범에 이미지를 저장한다. delegate를 구현하면 이미지 저장 여부를 어플리케이션에서 알 수 있다. (여기선 생략)

위 (UIImage*)captureContentsInScrollView:(int)selectRow 함수에 파라미터로 선택된 row를 전달하고 있다. 코드에서는 selectRow 파라미터가 사용되지 않았으나, 이 파라미터와 cell의 높이를 활용하여 captureSize를 조정하면 선택된 셀까지만 캡쳐되도록 할 수 있다. 아래 그림은 적용한 결과 화면이다.

선택한 row까지만 캡쳐

[iOS] Bitmap Image Context

대부분의 iOS 개발서적은 UIGraphicsBeginImageContext()를 이용한 화면 캡쳐 내용을 포함하고 있다. 그러나 아쉽게도 본인이 봤던 개발서에는 UIGraphicsBeginImageContext()의배경 설명 없이 스킬 위주로만 설명이 되어 있어 이를 활용하는데 어려움이 있었다. 이번 포스팅에서는 Bitmap Image Context에 대해 간단히 정리해본다.

iOS developer library의 Drawing and Creating Images 에 다음과 같은 설명이 있다.

Most of the time, it is fairly straightforward to display images using standard views. However, there are two situations in which you may need to do additional work:

두 번째 항목으로부터  bitmap image context의 용도를 파악할 수 있다.  화면에 바로 나타나지 않는 – offscreen – 이미지를 생성하고 싶을 때는 반드시 bitmap image context를 생성해야 한다.

bitmap image context를 생성하는 함수가 바로 UIGraphicsBeginImageContext()다. 화면 캡쳐를 생각해보면, 화면을 캡쳐한 이미지는 화면에 보이진 않고 내부적으로 생성되어 사진앨범에 저장된다.

UIKit에서 지원하는 UIGraphicsBeginImageContextWithOptions( CGSize size, BOOL opaque, CGFloat scale ) 함수는 iOS 4.0 이후부터 지원되며 각 옵션은 다음을 나타낸다.

  • size : bitmap context 사이즈. 생성될 이미지 사이즈와 연관있다. 
  • opaque : 생성될 이미지의 투명도 여부. 생성할 이미지가 투명도(alpha)를 가지고 있으면 NO, 불투명하면 YES. ( YES 의 퍼포먼스가 더 높다.)
  • scale : 0.0 이면 디바이스의 화면에 맞게 이미지가 결정된다. 개발자가 임의의 값을 넣을 수도 있다.

예를 들어 UIGraphicsBeginImageContextWithOptions(CGSizeMake(100.0,100.0), NO, 2.0) 으로 생성되는 이미지의 크기는 200 x 200으로 size 값에 scale 을 곱한 값이다.

iOS 4.0 이전에 지원했던 UIGraphicsBeginImageContext(CGSize size) 함수는 UIGraphicsBeginImageContextWithOptions 에서 scale = 1.0 인 것으로 레티나 디스플레이에서는 UIGraphicsBeginImageContext()로 생성된 이미지가 만족스럽지 않을 수도 있다.

UIGraphicsBeginImageContext를 이용한 이미지 생성은 보통 다음의 단계를 따른다.

-(void)createBitmapImage{

    // 1. bitmap context를 생성하고 이를 graphics stack에 넣는다.
    UIGraphicsBeginImageContextWithOptions(imageSize, NO, [UIScreen mainScreen].scale);

   // 2. UIKit 또는 Core Graphics 루틴을 활용하여 bitmap context에 이미지를 그린다.
   // ex) [UIImage drawInRect:], [CALayer renderInContext:] 등

    // 3. bitmap context로부터 이미지를 받아온다.
    UIImage *createdImage = UIGraphicsGetImageFromCurrentImageContext();

    // 4. bitmap context를 graphics stack으로부터 빼낸다.(bitmap context를 종료한다.)
    UIGraphicsEndImageContext();
}

(1)번에서 생성하는 bitmap context의 크기 – CGSize parameter 값 – 과 (2)에서 그릴 이미지 크기가 같아야(당연히 비율도 맞아야) 이미지 왜곡이 생기지 않는다. 만약 그릴 이미지 크기가 bitmap 크기보다 크다면 image가 짤린 것처럼 나올 수도 있다.

* Reference : iOS Developer Library – Drawing and Creating Images

순정 UITableView에서 Row개수만큼만 데이터 표시하기

UITableView에 데이터를 보여줄 때 데이터의 개수가 적으면 아래 그림처럼 보여지곤 한다. 데이터가 없음에도 화면에 빈 테이블뷰를 보여주는 것은 좀 구리다.

UITableView default

Row 개수만큼만 보여주고 싶으면 어떻게 해야 할까?

UITableView 및 UITableViewCell를 개발자가 별도로 재정의하여야만 할까? 물론 그래도 되지만 UITableView를 개발자가 구현한다면  Cell 재사용, Scroll View등 신경써야 할 부분이 많다.

UITableViewCell은 화면에 보여지는 데이터와 직접적인 관련이 있어 재정의하여 많이 사용된다. 그러나 UITableView는 UITableViewCell만큼 재정의해서 사용되진 않는다. 순정 UITableView를 이용해도 많은 부분 커버된다.

위 문제도 아주 간단한 방법으로 아래 그림처럼 구현이 가능하다.

UITableView after setting footerview

UITableView 하나로 구성된 테스트 프로젝트를 하나 만들었다. 코드는 아래와 같다.


@implementation ViewController{

    NSArray *_dataArray;
}

- (void)viewDidLoad
{

    [super viewDidLoad];
    // 테스트 데이터
    _dataArray = [NSArray arrayWithObjects:[NSString stringWithFormat:@"Row 1"],
                 [NSString stringWithFormat:@"Row 2"],
                 [NSString stringWithFormat:@"Row 3"], nil];

    _tableView.dataSource = self;
    _tableView.delegate = self;

    // tableFooterView를 정의. 사전에 tableFooterView가 기획되지 않았으므로 작은 영역으로 정의한다.
    UIView *footerView = [[UIView alloc]initWithFrame:CGRectMake(0, 0, 320, 10)];

    // footerView가 화면에 보이지 않도록 배경은 투명하게
    footerView.backgroundColor = [UIColor clearColor];

    // tableFooterView 설정
    _tableView.tableFooterView = footerView;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{

    return _dataArray.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

    static NSString *testIdentifer = @"testIdentifier";
    UITableViewCell *cell;

    cell = [tableView dequeueReusableCellWithIdentifier:testIdentifer];

    if(!cell){

       cell = [[UITableViewCell alloc]initWithStyle:UITableViewCellStyleDefault reuseIdentifier:testIdentifer];

    }

    cell.textLabel.text = [_dataArray objectAtIndex:indexPath.row];

    return  cell;
}

코드에서 보는 바와 같이 tableFooterView를 설정함으로써 간단히 구현된다.

UITableView의 배경색을 [UIColor clearColor] 로 설정하고, UITableViewCell을 재정의하여 Cell이 자체의 색상을 갖도록 하면(보통 Cell의 배경색은 UITableView의 배경색을 따름), 데이터가 없는 영역에는 UITableView 바로 아랫단의 뷰(위 예제의 경우 self.view)가 보여져 Row 개수만큼만 UITableView 영역이 설정된 것과 같은 효과를 얻을 수 있다.  (아래 그림)

UITableView with custom UITableViewCell

너무 간단한 방법이라 이렇게 포스팅하는게 우습기도 하지만, 본인이 젤 첨 UITableView를 공부할 때 위와 같은 구현법이 굉장히 궁금했으나 그 방법을 알아내기가 쉽지 않았다. UIView를 이용한 나만의 테이블뷰를 만들어야 하나 생각도 했었다.

UITableView의 tableHeaderView, tableFooterView, sectionHeaderView, sectionFooterView를 이용하면, UITableView만으로 다양한 형태의 화면 구성이 가능하다.

[iOS] Automatic Reference Counting (ARC)

iOS 5부터 Apple이 LLVM Compiler 를 채용함에 따라 ARC (Automatic Reference Counting)가 iOS 개발에 적용되었다.

Java나 C#만을 다룬 상태에서 iOS 4.x 후반대에 iOS 공부를 시작한 본인으로서는 개발 입문 후 곧 소개된 ARC가 큰 힘(?)이 되었다.

ARC 관련하여 개발 서적, iOS developer reference 등을 보았지만 가장 이해하기 쉬운 자료는 WWDC 2011 자료(애석하게도 WWDC 2011 Introduction Automatic Reference Counting 설명 자료는 Apple 개발자 계정이 있어야 볼 수 있음)였다. 이번 포스팅은 WWDC 자료를 바탕으로 ARC 에 대해 정리한다.

ARC 도입 이유

< from WWDC 2011 >

애플은 ARC 도입 이유는 다음과 같다.

  • 앱의 비정상 종료 원인 중 많은 부분이 메모리 문제. 또한 애플의 앱 승인 거부(Rejection)의 대다수 원인 중 하나. 
  • 많은 개발자들이 수동적인 (retain/release) 메모리 관리로 힘들어함.
  •  retain/release 로 코드 복잡도가 증가.

결국 개발자가 좀 더 편하게 앱 개발을 할 수 있도록 해주겠단 의미다. 개발자는 객체 관계에 더 집중하면 된단다.

ARC 란?

< from WWDC 2011 >

ARC는 Automatic Reference Counting의 약자로 기존에 수동(MRC라고 함)으로 개발자가 직접 retain/release를 통해 reference counting을 관리해야 하는 부분을 자동으로 해준다.

위 자료를 보면 Object가 노란색으로 강조되어 있다. 실제로 ARC는 Object만을 대상으로 하며 이로 인해 구조체나 C 기반의 Core Foundation 사용에는 제약이 존재한다. 이에 대해서는 아래에서 다시 언급하도록 하겠다.

< from WWDC 2011 >

ARC는 GC (Garbage Collector)와는 다르게 런타임이 아닌 컴파일 단에서 처리된다.

GC는 런타임에 메모리를 검사하기 때문에 앱 퍼포먼스에 악영향을 준다. 그러나  ARC는 개발자가 직접 코딩하던 retain/release를 컴파일러가 자동으로 코드에 삽입시키므로, 동작시에는 MRC와 동일하여 성능 저하를 유발하지 않는다.

Apple은 iOS SDK 문서에서 차후 Mac OSX에서도 ARC가 GC를 대체할 것이라 밝히고 있다. 애플에서 제공하는 ARC 퍼포먼스 향상 효과는 다음과 같다.

< from WWDC 2011 >

ARC를 위한 규칙

컴파일러가 자동으로 retain/release 코드를 삽입시키기 위해 몇 가지 규칙을 제안한다. 이를 알아보자.

< from WWDC 2011 >

[첫 번째]  retain/release/autorelease 를 이용하지 마라.

더 이상  retain/release/autorelease를 코드에 직접 삽입할 필요가 없다. 컴파일러가 알아서 다 해준다. 블록(blocks)도 Object이므로 기존 블록에 사용하던 copy, autorelease는 불필요하다. 메소드의 return에 대한 autorelease도 고려할 필요 없다. 그냥 짜면 컴파일러가 알아서 해준다.

dealloc 메소드는 사용할 수는 있지만 이는 인스턴스 변수들의 메모리 해제(release)가 아닌 자원 관리 차원에서 허용된다. 또한 개발자에 의해 재정의된 dealloc 메소드에서는 [super dealloc]을 호출해서는 안된다

< from WWDC 2011 >

[두 번째 규칙] C 구조체 내에 Object 타입을 사용하지 마라.

ARC는 Object만을 고려한다. 구조체 내의 Object는 ARC가 계산하기 어렵다. 그냥 구조체가 아닌 Object  (Objective-C Class)를 사용하라.

< from WWDC 2011 >

[세 번째 규칙] Core Foundation은 Objective-C의 Object 타입이 아니므로 Core Foundation과 Object 간의 명시적인 타입 캐스팅이 필요하다.

추가로 Core Foundation 스타일의 객체에는 CFRetain, CFRelease 와 같은 메모리 관리 함수를 사용할 수 있다. 다음을 참조하자 - “Managing Toll-Free Bridging”

< from WWDC 2011 >

[네 번째 규칙] NSAutoreleasePool 사용하지 마라. NSAutoreleasePool은 내부적으로 보면 실제로는 Object가 아니다. 대신 @autoreleasepool{}을 사용하라. 속도도 더 빠르다. @autoreleasepool{}은  loop에 쓰이는 경우가 많다. NSError에도 쓰이지만 자주 사용되지는 않는다. 그래도 경험상 거의 사용할 일이 없는 듯 하다.

새로운 지시어

1) 프로퍼티 속성

아래 예처럼 strong, weak 키워드가 프로퍼티에 사용된다.

// The following declaration is a synonym for: @property(retain) MyClass *myObject;
@property(strong) MyClass *myObject;

/ The following declaration is similar to "@property(assign) MyClass *myObject;"
/ except that if the MyClass instance is deallocated,
// the property value is set to nil instead of remaining as a dangling pointer.

@property(weak) MyClass *myObject;

strong은 retain과 의미가 거의 같다.

weak는 assign 키워드처럼 참조 카운트를 증가시키지 않는다.  그러나 assign과는 다르게 참조하는 객체가 해제(deallocated)되면 weak로 선언된 프로퍼티는 자동으로 nil이 대입된다. assign의 경우엔 nil 이 대입되지 않아 의도치 않은 포인터를 가리킬 수 있었다. assign과 거의 동일한 의미의 지시어는 unsafe_unretained 이다.

ARC는 강한 참조 사이클(strong reference cycle)을 방지하지 못한다. 이를 위해 개발자가 강한 참조 사이클이 발생하지 않도록 약한 참조(weak)를 잘 사용해야 한다. (참고- “Practical Memory Management”)

2) 변수 지시자(variable qualifier)

  • __strong : 디폴트 지시자. 객체의 소유권을 획득한다. (retain count 증가)
  • __weak : 객체의 소유권을 가져오지 않고 참조한다. 참조한 객체가 소멸되면 nil 로 설정된다.
  • __unsafe_unretained : __weak 와 비슷하지만 참조 객체 해제되어도 nil 로 재설정되지 않는다. 변수의 값은 특정 포인터를 계속 가리키고 있으므로 변수 사용에 유의해야 한다. 오류가 발생할 수 있다.
  • __autoreleasing : 보통 메소드의 (id *) 타입 파라미터 인수에 사용하며 함수 리턴시 자동으로 해제된다. Cocoa Framework 의 간편 생성자(Convenience Initializer)역시 __autoreleasing 타입의 객체를 반환함.

__strong, __weak, __autoreleasing 변수는 생성시 자동으로 nil 로 초기화 된다. 변수 선언 형식은 다음과 같다.

ClassName * qualifier variableName;

__weak 를 사용할 경우 아래와 같은 실수를 하지 않도록 주의해야 한다.

NSString * __weak string = [[NSString alloc] initWithFormat:@"First Name: %@", [self firstName]];
NSLog(@"string: %@", string);

결과적으로  string 변수는 바로 nil 로 설정된다.

단계적으로 다음과 같이 이해하면 쉽다.

1) 우측 구문이 샐행되어 NSString 객체 생성.

2) NSString 객체가 string 변수에 할당

3) string 변수는 __weak으로 선언. 따라서 기존의 참조 카운트를 그대로 갖는다. 따라서 초기화된 NSString의 참조카운트 0(NSString 객체를 강한 참조로 소유하는 다른 객체 없음)이 그대로 넘어가고, 참조 카운트가 0이므로 weak 특성에 따라 바로 nil 로 재설정.

__autorelease 는 일반적으로 잘 사용되지 않는다.

기타

1) 지원 OS

  • Xcode 4.2
  • Mac OS X v10.6 and v10.7 (64 – bit application)
  • iOS 4 and iOS 5
  • weak 참조는 OS X 10.6 및 iOS 4 에서 미지원. 따라서 weak 참조를 지원하는 완벽한 ARC 기능은 OS X 10.7 및 iOS 5 버전부터

2) NSAllocateObject , NSDeallocateObject 를 사용할 수 없다. alloc을 이용해서 객체를 생성하면, Runtime이 객체 해제(deallocate)를 알아서 처리한다.

3) ARC 는 다음과 같은 메소드 네이밍에 대한 제약을 갖는다.

  • 프로퍼티 접근자(accessor) 이름이 new 로 시작해서는 안된다. new 로 이름을 명명이 가능한 경우는 아래와 같이 getter 의 이름을 다른 이름으로 명시적으로 지정했을 경우이다.
// Won't work:
@property (strong, nonatomic) NSString *newTitle;
// Works:
@property (strong, nonatomic, getter=theNewTitle) NSString *newTitle;

 

* Reference

 

UIAlertView, delegate 그리고 ARC

며칠 전 직면했던, 지금 돌이켜 보면 사소한(?) 상황에 대해 정리한다. ARC 를 너무 생각없이 편하게만 – C#과 Java 처럼 – 사용하여 발생한 문제다.

핵심 상황은 다음과 같다.

UIViewController인 TestUIViewController가 있다. TestUIViewController는 알림 메시지(UIAlertView) 통보를 AlertViewManager에게 위임한다. AlertViewManager는 상황을 판단하여 필요한 경우 알림 메시지를 띄운다. 예시 코드는 다음과 같다.

// View Controller
@implementation TestUIViewController

-(void)alertTrigger{

    [[[AlertViewManager alloc] init] alert];
}
@end

// Alert 을 담당하는 별도 클래스
@interface AlertViewManager : NSObject

// View Controller에서 알림 메시지 호출을 위해 사용
-(void)alert;

@end

@implementation AlertViewManager

-(void)alert{

    UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:@"Alert Test" message:@"ARC가 delegate를 지울 수도 있어요" delegate:self cancelButtonTitle:@"어쩌라고" otherButtonTitles:@"땡큐", nil];

    [alertView show];

}

// UIAlertViewDelegate handler
-(void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex{

    switch (buttonIndex) {
       case 0:

          // do something
          break;

       case 1:

          // do something
          break;

       default:
          break;
     }
}

@end

코드를 실행하여 알림 메시지 창을 띄운 후, 알림 메시지창 버튼을 클릭하면 앱은 종료(Crash)된다. 문제 원인은 TestViewController에서 사용하는 AlertViewManager 객체 life cycle과 관련있다.

Alert View

TestViewController의 alertTrigger 메소드 안에서 생성된 AlertViewManager 객체는 TestViewController의 속성이나 인스턴스 변수로 할당이 되지 않고 바로 사용된다. 이로 인해 alertTrigger 메소드 종료시점에 AlertViewManager 객체는 참조 관계를 잃어 ARC에 의해 해제(release)된다.

문제는 AlertViewManager가 UIAlertViewDelegate로 할당되었다는 사실이다. delegate는 retain cycle을 피하기 위해 __weak 참조를 기본으로 하고 있다. ([iOS]Practical Memory Management 참조)

delegate 할당이 retain count를 증가시키지 않으므로 AlertViewManager 객체가 release되면 delegate=self 로 할당된 delegate 도 해제된다.  결국 알림 메시창의 버튼 클릭 이벤트를 처리할 delegate 객체가 없어진 상태에서 버튼이 클릭되어 앱이 종료된 것이다.

Instruments의 Zombie를 통해 정확히 확인할 수 있다.

UIAlertView Zombie

위 그림의 RefCt (Reference Count)의 변화를 보면 UIViewController의 메소드 (캡쳐 화면 메소드 이름과 예시 상황에서 설명한 메소드 이름이 상이하지만 이해에 문제 없을 것 같다)에서 AlertViewManager는 할당되고 해제된다. AlertView의 buttonClicked: 에서 Zombie 객체가 확인된다.

이 상황의 해결법 중 하나는  AlertViewManager를 TestUIViewController의 변수로 추가시켜 ViewController가 소멸될 때까지 AlertView release가 되지 않도록 방지하는 것이다. 수정되는 코드는 아래와 같다.

// View Controller
@implementation TestUIViewController{

    // private 필드로 지정. (인스턴스 생성 코드는 생략)
    AlertViewManager *alertManager;
}

-(void)alertTrigger{

    // 인스턴스 변수를 통해 alert 호출
    [alertManager alert];
}
@end

생각해보면 별 것도 아니고 실제로 자주 발생하는 문제도 아니다. 그러나 ARC의 편의를 누리려면  ARC에 대해 좀 더 명확히 알 필요가 있을 것 같다.

[iOS] 로컬 알림 취소 – UILocalNotification 저장 및 취소

이전 포스팅  [iOS] 로컬 알림 (Local Notifications)에 이어 로컬 알림 저장 및  취소에 대해 좀 더 정리해보려 한다.

이전 포스팅에서도 언급 했지만, 알림 취소의 의미는

  • 알림 대기 중인 알림의 취소
  • 이미 도착한 알림의 취소 (iOS 디바이스 알림 센터 목록에서의 삭제)

로 구분된다.

알림 등록에 사용한 UILocalNotification을 저장하지 않고 알림을 취소하는 방법은 아래와 같다. (로컬 알림과 연계된 application delegate 메소드 내용은 이전 포스팅 참고)

// (1) 모든 알림 취소
// 임의의 메소드에서 다음 코드로 가능

[[UIApplication sharedApplication] cancelAllLocalNotifications];

// (2) 특정 알림 취소. Application delegate 파라미터 이용

// (2-1)
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions{

    UILocalNotification *localNotif = [launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];

    if(localNotif){

        ...
        [application cancelLocalNotification:localNotif];
    }

    ...
    return YES;

}

// (2-2)
-(void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification{
    ...
    [application cancelLocalNotification:notification];
    ...
}

위 코드 (1) 방법은 앱에 등록된, 삭제되지 않은 모든 알림을 삭제한다. 알림센터에 보여지는 알림도 포함된다. (2) 방법은 도착한 알림만 삭제하여 알림센터에 보이지 않도록 한다.

간단한 알림의 경우 위 방법으로도 충분하다. 사용자가 알림 시각을 변경하면 기존에 등록된 알림을 cancelAllLocalNotifications로 지우고 새로 등록한다. 알림이 도착하면 application delegate에서 알림을 취소하여 확인된 알림이 알림센터에 보이지 않도록 한다.

그럼 어떠한 경우에 UILocalNotification을 저장해야 할까?

  • 어플리케이션에서 사용하는 로컬 알림의 종류가 다양하고 많을 경우, 
  • Application delegate 이외의 다른 이벤트에서 알림센터의 알림 항목을 삭제하고 싶은 경우

앱에서 사용하는 로컬 알림의 종류가 다양하고 - 특정 시간에 보내는 알림, 바로 보내는 알림등 앱 내에서 성격에 따라 구분 – 개수가 많다면, 하나의 알림을 취소하기 위해 cancelAllLocalNotifications으로 모든 알림을 지우고 다시 새로 등록하는 구조는 바람직하지 않다. 취소하고자 하는 알림만 취소하는 것이 효율적이다.

수신한 알림이 사용자가 원할 때까지 – 사용자가 확인한 경우에도 – 알림센터에 남아있길 원하는 경우에도 UILocalNotification을 저장하여 필요 시점에 취소해야 한다.

UILocalNotification 저장 시 NSUserDefaults를 이용하면 편하다. 직접 UILocalNotification 객체를 NSData로 변환 후 파일에 저장할 수 있지만 NSUserDefaults는 이를 쉽게 해준다.

(NSUserDefaults가 UILocalNotification 저장소로 적합한가에 대해서는 검토가 좀 더 필요하다. 본인도 스택오버플로우 검색을 통해 NSUserDefaults를 알았는데, 해당 클래스는 사용자의 앱 설정값 등 간단한 데이터를 저장하는 용도로 만들어졌다. 일단 지금까지 본인이 테스트한 바로는 문제가 발생하지 않았다.)

아래는 NSUserDefaults를 이용하여 UILocalNotification을 저장하고 반환하여 로컬 알림을 취소하는 예제 코드다.

//
// 로컬 알림 등록 메소드에서 UILocalNotification 저장
-(void)fireLocalNotification{

    // UILocalNotification 객체 생성
    UILocalNotification *noti = [[UILocalNotification alloc]init];
    noti.fireDate = [NSDate dateWithTimeIntervalSinceNow:5];
    noti.timeZone = [NSTimeZone systemTimeZone];
    noti.alertBody = @"Just Do It";

    // UILocalNotification 을 NSData로 변환.
    // NSKeyedArchiver 이용
    NSData *notiData = [NSKeyedArchiver archivedDataWithRootObject:noti];

    // NSUserDefaults에 NSData 변환된 UILocalNotification 저장
    // 키값으로 알림메시지 사용 (테스트용)
    [[NSUserDefaults standardUserDefaults] setObject:notiData forKey:noti.alertBody];

    // 어플리케이션에 알림 등록
    [[UIApplication sharedApplication] scheduleLocalNotification:noti];
}

// 로컬 알림 취소
-(void)cancelLocalNotification:(NSString*)key{

    // key 값으로 NSData 타입의 UILocalNotification 획득
    // 파라미터 key는 저장시 등록한 값과 동일 가정
    NSData *dataNoti = [[NSUserDefaults standardUserDefaults] objectForKey:key]

    // NSData 타입을 UILocalNotification으로 변환
    // NSKeyedUnarchiver 이용
    UILocalNotification *notif = [NSKeyedUnarchiver unarchiveObjectWithData:dataNoti];

    // NSUserDefaults에 저장된 UILocalNotification 이용하여 알림 삭제
    [[UIApplication sharedApplication] cancelLocalNotification:notif];
}

NSUserDefaults에 저장할 수 있는 데이터 타입은 Property Lists에 사용되는 타입으로 NSArray, NSDictionary 등 다양하다. 위 코드에서는 UILocalNotification을 NSData 타입으로 저장하고 반환하였지만, 콜렉션 타입을 이용한다면 다른 방법으로도 가능하다. NSUserDefaults Class Reference를 확인하자.

NSObject와 NSData 간의 변환은 NSKeyedArchiver와 NSKeyedUnarchiver를 이용한다.

테스트 중 저장된 UILocalNotification을 이용한 알림 취소가 실패하는 경우를 발견하였다.

  • [UIApplication scheduleLocalNotification:]로 알림 등록 – 정상적으로 취소
  • [UIApplication presentLocalNotificationNow:]로 알림 등록 –  알림 취소 되지 않음 (디바이스 알림센터에서 알림이 삭제되지 않고 보임)

참고로 Application delegate 의 파라미터를 이용한 알림 취소는 위 두 메소드 모두 잘 된다.

iOS SDK를 찾아봤으나 정확한 원인을 밝힐 수  없었다. 두 메소드 간의 어떤 차이점이 이런 현상을 유발했는지 궁금하다.

[iOS] 로컬 알림 (Local Notifications)

iOS는 로컬 알림(Local Notifications)과 푸쉬 알림(Push Notifications – Remote Notifications 라고도 함) 방법을 제공하여 앱이 Foreground (iOS App State 참고) 에서 실행 중이 아닐 때에도 사용자에게 정보가 전달되도록 한다.

두 가지 알림 방법 중 Local Notification에 대해 이번 포스팅에서 정리한다. iOS Developer Library  -글 하단 Reference 링크 참고 – 에 알람 등록/처리 등 관련 내용이 잘 설명되어져 있다. 그러나 문서 내용 중에는 본인을 헷갈리게 한 부분도 있다. 그 부분도 같이 정리한다.

Local Notifications

사용자 측면에선 본인에게 온 알림이 로컬 알림인지 푸쉬 알림인지 구별하기 어렵다. 즉, 두 가지 알림 방법이 사용자에게 보여지는 경험은 거의 동일하다.

로컬 알림과 푸쉬 알림의 차이는 개발에 있다. 푸쉬 알림은 별도의 서버에서 모바일 디바이스로 알림을 전송한다. 반면 로컬 알림은 모바일 디바이스의 앱이 알림을 등록하고 iOS가 이를 전달한다.

로컬 알림은 iOS 4 이후 부터 지원하며 Mac OS X에서는 지원하지 않는다. (푸쉬 알림은 iOS, Mac OS X 모두 지원한다.)

로컬 알림은 시간 기반(time-based) 특성이 강하다. 즉, 사용자에게 전달하고자 하는 메시지와 메시지를 전송하는 시간이 기본이 된다.

등록된 알림은 앱이 Foreground에서 실행되는 경우엔 표시되지 않는다. 그러나 화면에 알림이 나타나지만 않을 뿐 코드상에서 처리할 수 있다. (추가적인 내용은 아래에서 다룸)

Local Notification 등록

로컬 알림은  UILocalNotification 과 UIApplication을 사용한다.

UILocalNotification의 속성은 크게 3가지로 분류한다.

  • Scheduled time : 알람 발생 시각. 주기를 정하여 반복 설정 가능. 타임존 설정 가능.
  • Notification type : 알림 메시지, 액션 버튼 타이틀, 뱃지 아이콘, 알림 사운드.
  • Custom data : 알림 처리 로직에서 사용할 수 있는 사용자 정보.

로컬 알림을 등록하는 위치나 시점에 대해서는 특별한 제약은 없다. 앱이 백그라운드로 진입 시 또는 사용자가 특정 버튼을 눌렀을 때 등 대부분 가능하다. 다음은 로컬 알림을 등록하는 샘플 코드이다.

// 알림 등록 메소드
-(void)fireLocalNotification{

    // UILocalNotification 객체 생성
    UILocalNotification *noti = [[UILocalNotification alloc]init];

    // 알람 발생 시각 설정. 5초후로 설정. NSDate 타입.
    noti.fireDate = [NSDate dateWithTimeIntervalSinceNow:5];

    // timeZone 설정.
    noti.timeZone = [NSTimeZone systemTimeZone];

    // 알림 메시지 설정
    noti.alertBody = @"Just Do It";

    // 알림 액션 설정
    noti.alertAction = @"GOGO";

    // 아이콘 뱃지 넘버 설정. 임의로 1 입력
    noti.applicationIconBadgeNumber = 1;

    // 알림 사운드 설정. 자체 제작 사운드도 가능. (if nil = no sound)
    noti.soundName = UILocalNotificationDefaultSoundName;

    // 임의의 사용자 정보 설정. 알림 화면엔 나타나지 않음
    noti.userInfo = [NSDictionary dictionaryWithObject:@"My User Info" forKey:@"User Info"];

    // UIApplication을 이용하여 알림을 등록.
    [[UIApplication sharedApplication] scheduleLocalNotification:noti];
}

등록 코드는 간단하다. UILocalNotification 객체를 생성하여 각 속성에 적당한 값을 넣고 UIApplication을 이용하여 알림을 등록하면 된다.

timeZone의 경우 systemTimezone을 설정하였다. systemTimeZone은 디바이스에 설정된 시각대를 말하며 defaultTimeZone은 개발자가 직접 설정해서 사용할 수 있는 시각대를 의미한다. 예를 들어 해외 여행을 하는 경우, 아이폰의 변경된 시각대를 적용하려면 systemTimeZone을 쓰면 된다. (참고 : Difference among time zone convenience methods).

UIApplication의 scheduleLocalNotification: 메소드 외에 presentLocalNotificationNow: 메소드로 알림을 등록할 수 있다. 이 메소드는 UILocalNotification의 fireDate는 무시하고 앱에 바로 알림이 전달되도록 한다.

UIApplication의 scheduledLocalNotifications 속성을 이용하여 한 번에 여러 UILocalNotification을 등록할 수도 있다. 이 속성은 iOS 4.2버전 이전에는 read-only 였으나 setter 메소드가 추가되어 NSArray타입으로 한 번에 등록할 수 있다. 이 속성이 설정되면 이전의 로컬 알림은 다 취소되므로 사용에 주의해야 한다.

Local Notification 알림 메시지 타입

본인이 헷갈렸던 부분 중 하나다. 알림 등록 시 설정하는 UILocalNotification 속성값과 이 값들이 실제로 화면에 보여지는 모습에 대해 알아보자.

우선 위 코드를 iOS Simulator에서 동작시키면 아래 그림과 같이 배너 형태로 나타난다. (본인의 개발환경은 Xcode 4.5.2, iOS 6). 참고로 본인은 버튼의 클릭 이벤트에 위 코드를 실행시켰으며 5초가 되기 전에 홈버튼을 눌러 앱을 백그라운드 상태로 만들었다. 앱이 전면에서 실행 중이면 알림이 표시되지 않는다.

Local Notification Test in Simulator

iPhone을 써본 사용자라면 알림은 배너와 알림창 타입이 있다는 것은 알고 있을 것이다. 그렇다면 이것은 어디서 결정되어지는 것일까? iOS Developer Library에서는 푸쉬 알림 설명 부분에서만 “설정앱 → 알림”에서 이를 설정할 수 있다고 언급하고 있다.

그러나 실제로 로컬 알림도 설정앱에서 알림 스타일 설정이 가능하다. 아이폰에 연결시켜 코드를 실행하고 설정앱을 확인해 보면 알림 스타일이 ‘배너’로 설정되어 있음이 확인된다. iOS Simulator의 설정앱에는 알림 설정 부분이 없다. iOS Simulator에 배너 스타일로 알림이 오는 것을 보면 현 iOS 버전에서는 배너가 기본 스타일인 것 같다.

alertAction은 알림이 알림창 타입인 경우 버튼 ‘View’를 대체 – 알림창엔 ‘Cancel’과 ‘View’ 버튼이 있다 – 하는 문자열을 나타낸다. 디바이스가 잠겨 있는 경우에는 잠금 해제에 나타나는 메시지를 의미한다.  (nil이 설정되면 디폴트 값이 적용됨)

아래는 위에서 등록한 알림이 잠금 상태에서 왔을 때의 모습이다. alertAction에 설정한 “GoGo”가 어떻게 적용되는지 확인된다. 설정앱을 통해 알림스타일을 알림창으로 변경해도 디바이스 잠금상태에서는 아래와 같이 표시된다.

Local Notification test in Simulator when device locked

알림 효과음은 soundName으로 설정하며 직접 제작한 사운드도 설정할 수 있다. nil을 설정하면 소리가 나지 않는다 .

iOS Developer Library를 보면 아래 그림과 같이 버튼이 하나인 알림창을 띄울 수 있다고 설명되어져 있다.

< from iOS developer library >

그러나 UILocalNotification 속성 중  hasAction = NO, alertAction = nil 설정을 하여도 위와 같은 효과를 얻을 수 없었다. 푸쉬 알림에서는 어떨지 모르겠으나 로컬 알림에서는 기본적으로 버튼 하나의 알림창은 지원하지 않는 듯 하다.

UILocalNotification의 applicationIconBadgeNumber을 설정한다고 해서 앱 우측 상단에 숫자가 표시되는 것은 아니다. 실제로 앱의 뱃지 아이콘 숫자는 UIApplication의 applicationIconBadgeNumber에 의해 결정된다. 로컬 알림 등록시에 설정한 UILocalNotification의 applicationIconBadgeNumber는 알림 처리부에서 UIApplication의 applicationIconBadgeNumber를 수정할 때 사용되기도 한다. 설정하지 않아도 상관없다.

할일 앱의 경우 로컬 알림과 상관없이 미완료 항목 숫자에 맞게 직접 UIApplication의 applicationIconBadgeNumber를 설정할 수 있다.

Local Notification 응답

알림을 처리할 때, 다음의 경우를 고려하여야 한다.

  • 앱 미실행 (종료) 상태일 때,
  • 앱 실행 중 (Background 또는 Foreground) 일 때

앱 종료상태에서 알림 액선(알림창의 버튼 or 잠금상태의 잠금 해제)  또는 디바이스의 알림 센터에 보이는 알림을 클릭할 경우, application delegate의 didFinishLaunchingWithOptions: 이 호출된다.

이 메소드는 알림과 무관하게 앱이 종료상태에서 실행될 때에도 호출되는데, 파라미터인 launchOptions를 통해서 알림을 통한 앱 실행인지 구분 가능하다.


// application delegate method
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{

    // 알림을 통한 진입인지 확인
    UILocalNotification *localNotif = [launchOptions objectForKey:UIApplicationLaunchOptionsLocalNotificationKey];
    if(localNotif){
        // 알림으로 인해 앱이 실행된 경우라면..
        // localNotif.userInfo 등을 이용하여
        // 알림과 관계된 화면을 보여주는 등의 코드를 진행할 수 있음.
    }
}

앱이 종료 상태이고 UIApplication의 applicationIconBadgeNumber가 설정되어 있을 때, 앱 아이콘을 클릭하여 앱을 샐행시키는 경우는 어떨까? 알림 액션으로부터 실행된 경우와 동일할까?

이 경우엔 알림과 무관하게 실행되는 경우와 동일하다. 즉 위 코드에서 localNotif 는 nil 이다.

앱이 실행 중인 경우는 Background/Foreground와 상관없이 application delegate의 didReceiveLocalNotification: 메소드가 호출된다. 파라미터는 UILocalNotification 타입이다.

Foreground 경우 알림이 화면에 나타나지 않지만, 위 메소드가 실행은 되므로 필요에 맞게 처리해야 한다.

Foreground와 Background 상태일 때 처리하는 로직이 달라야 한다면, UIApplication의 applicationState를 이용하면 구분한다. (Background 경우는 UIApplicationStateBackground가 아닌 UIApplicationStateInactive 로 확인한다.)


// application delegate method
-(void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification
{
    if(application.applicationState == UIApplicationStateActive){

        // Foreground에서 알림 수신
    }

    if(application.applicationState == UIApplicationStateInactive){

        // Background에서 알림 액션에 의한 수신
        // notification.userInfo 이용하여 처리
    }
}

Local Notification 취소

UIApplication의 cancelAllLocalNotifications 메소드와 cancelLocalNotification: 메소드를 이용하면 로컬 알림을 취소할 수 있다.

로컬 알림은 전달 예정 중인 알림과 이미 전달된 알림으로 나눌 수 있다.

전달된 알림은 위에서 설명한 application delegate 메소드에 의해 처리된 후, 취소를 하지 않으면 아이폰의 알림 센터에 그대로 보여지게 된다.

알림 확인 즉시 확인된 알림이 알림센터에서 보여지지 않게 하기 위해서는, application delegate 메소드의 파라미터로 전달된 UILocalNotification 객체를 이용하여 cancelLocalNotification: 를 호출한다.

아직 전달되지 않은 로컬 알림을 취소할 때도 동일한 메소드를 사용한다. 이 경우에는 등록한 UILocalNotification 객체를 어딘가에 저장시켜  필요 시점에 사용할 수 있어야 취소가 가능하다.

cancelAllLocalNotification 메소드는 인자가  없다. 이 메소드는 알림 센터에 보이거나 또는 앞으로 전달 예정인 로컬 알림 모두를 취소 시키므로 사용에 주의해야 한다.

그 밖에

iOS 디바이스는 알림을 최신 64개 알림으로 제한한다.  iOS는 이를 초과하는 알림은 폐기한다. 또한 설정앱에서 확인해보면 각 앱마다 알림 센터에서 보여지는 알림은 기본 5개, 최대 10개로만 설정 가능하다. 알림이 남발하지 않도록 알림 메시지 디자인에 신경 쓸 필요가 있다.

로컬알림을 학습하며 이런 저런 경우를 테스트해보니, iOS에서 알림 관련 API가 상대적으로 부족한 느낌이 들었다. 개발자가 제어할 수 있는 부분이 좀 더 많았으면 한다.

특히 알림센터 활용에 대한 부분이 아쉬웠다. 실제 알림(배너, 메시지) 없이 알림 센터에 표시만 할 수 있다던가, 아니면 알림 센터에 남아있는 알림 리스트를 받아 온다던가, 아니면 사용자가 알림센터에서 알림을 클리어 하는 이벤트를 처리할 수 있다던가 하는 기능들이 추가된다면 알림센터를 더 잘 활용할 수 있지 않을까 싶다.

* Reference :

UITableViewCell 위치 이동: 키보드 피하기

UITextField 를 서브뷰로 가지고 있는 UITableViewCell.  셀을 탭하면  UITextField는 에디트 모드로 전환되며, 이 때 화면 하단에서 키보드가 올라온다.  편집하고자 하는 셀이 하면 하단에 위치한 경우, 셀의 위치가 화면 상단으로 옮겨지지 않으면 에디트 모드의 셀은 키보드에 가려진다.

이 상황을 해결하는 방법은 여러가지가 있겠지만, 본인이 확인한 두 가지 방법을 정리한다.

1) UIScrollView 이용

UITableView는 UIScrollView를 상속하고 있기에, 관련 메소드가 있는지 찾아보니 다음의 메소드가 눈에 띄었다.

- (void)scrollToRowAtIndexPath:(NSIndexPath *)indexPath atScrollPosition:(UITableViewScrollPosition)scrollPosition animated:(BOOL)animated;

SDK 설명을 보면,  indexPath로 정의된 row를 화면의 특정위치- scroll postion- 로 이동시키는 메시지다.

UITextField를 contentView로 갖는 20개의 UITableViewCell을 만들고, UITextField가 편집 모드일 때 위 메시지를 전송해보았다.


// 20개 cell 반환
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
    return 20;
}

// UITableViewCell 반환
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

    // cell 객체 생성 코드 생략
    ...
    // UITextField 생성
    UITextField *textField = [[UITextField alloc]initWithFrame:CGRectMake(5, 0, cell.bounds.size.width - 5, cell.bounds.size.height)];

    [cell.contentView addSubview:textField];

    // 화면에 row index 를 표시.
    textField.text = [NSString stringWithFormat:@"Cell-%d",indexPath.row];

    textField.contentVerticalAlignment = UIControlContentHorizontalAlignmentCenter;
    textField.delegate = self;

    // UITableView의 scroll 메소드 사용 때 사용될 row를 textField.tag에 저장.
    // UITextFieldDelegate 에서 사용
    textField.tag = indexPath.row;

    return cell;
}

-(BOOL)textFieldShouldReturn:(UITextField *)textField{

    // edit 모드 종료. 키보드 사라짐
    [textField resignFirstResponder];
    return NO;
}

(void)textFieldDidBeginEditing:(UITextField *)textField{

    // cell이 선택되어 textField가 편집모드이면 scroll 메시지 전송 : UITableViewScrollPositionTop
    [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:textField.tag inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:YES];

}

위 코드만으로는 원하는 결과를 얻지 못한다. 마지막 Cell 이 화면에 보여지 않는 위치에선 선택된 Cell이 UITableView의 가장 위에 위치하도록 스크롤이 되지만, 마지막 Cell이 화면에 보이는 위치에서는 셀을 선택해도 스크롤은 작동하지 않았다.

scroll to top postion -2

scroll to top postion -3

위 그림은 6번, 13번, 17번 cell 선택시 화면이다. 13번과 17번 선택에선 스크롤이 동작하지 않는다. 심지어 17번 cell은 화면에 가려진다.

이 상태에서 UIScrollView의 contentInset 속성을 적용하면 17번 셀이 키보드에 가려지는 현상은 피할 수 있다. contentInset 속성에 키보드 높이만큼 버퍼 공간을 제공하면 UITableView의 마지막 셀이 선택되더라도 셀이 키보드에 가려지지 않는다.

수정되는 코드는 다음과 같다. 키보드 높이는 테스트 상 portrait view 기준 216 px로 고정하였다. (iOS developer library 의 Managing Keyboard 보면 키보드 높이를 받아오는 방법 및 키보드가 화면을 가리는 현상의 솔루션 – 본인도 이번 블로그 정리하면서 발견하였다. – 에 대한 내용이 설명되어 있다.)


(void)textFieldDidBeginEditing:(UITextField *)textField{

    // keyboard 높이만큼 스크롤 뷰에 버퍼 공간 추가
    _tableView.contentInset = UIEdgeInsetsMake(0,0,216,0);

    // cell이 선택되어 textField가 편집모드이면 scroll 메시지 전송 : UITableViewScrollPositionTop
    [_tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:textField.tag inSection:0] atScrollPosition:UITableViewScrollPositionTop animated:YES];

}

(void)textFieldDidEndEditing:(UITextField*)textField{

    // 버퍼 공간 제거
    _tableView.contentInset = UIEdigeInsetsZero;
}

마지막 셀을 선택해도 아래와 같이 키보드는 보인다. 그래도 선택된 셀이 가장 위로 올라가지 않는다.

< 마지막 셀 선택 >

2) UITableViewCell 자신의 위치 변경

스크롤이 어느 위치에 있던 간에 선택된 셀이 일관되게 화면 상단에 위치하고 싶다면, UITableViewCell의 위치를 직접 변경하여 구현할 수 있다.

UITableView는 visibleCell 메소드를 제공하여 현재 보이는 cell을 직접 제어할 수 있도록 한다. cell의 좌표를 이용하여 화면 상단으로부터 이격 정도를 계산한 후, cell의 y좌표를 변경하는 코드로 원하는 바를 구현한다.

수정되는 코드는 다음과 같다

(void)textFieldDidBeginEditing:(UITextField *)textField{

    // 선택된 cell 반환
    UITableViewCell *editCell = [_tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:textField.tag inSection:0]];

    // _offset은 private instance 변수. 화면 상단과 선택된 셀과의 이격을 저장.
    _offset =  _tableView.contentOffset.y - editCell.frame.origin.y;

    // 화면에 보이는 cell을 받아옴
    for (UITableViewCell *cell in [_tableView visibleCells]) {

        // 자연스러운 이동 위한 에니메이션
        [UIView animateWithDuration:0.3 animations:^{

            // 모든 cell을 _offset만큼 이동
            cell.frame = CGRectOffset(cell.frame, 0, _offset);
        }];
    }

    // 편집 중에 scroll 금지
    _tableView.scrollEnabled = NO;
}

(void)textFieldDidEndEditing:(UITextField*)textField{

    for (UITableViewCell *cell in [_tableView visibleCells]) {
        [UIView animateWithDuration:0.3 animations:^{

            // 모든 cell을 다시 제자리로. (-_offset)
            cell.frame = CGRectOffset(cell.frame, 0, -_offset);
        }];
    }

    // 편집 후 scroll 가능
    _tableView.scrollEnabled = YES;
}

셀이 선택되어 셀 위치가 상단으로 이동한 후에 스크롤을 하면 위치가 엉키게 되어 화면이 이상해진다. 이를 방지하기 위해 편집 모드 진입 시 스크롤을 금지하도록 하고 편집 모드 종료 후 원상복귀 시켰다.

< 18번 cell 선택 >

테스트에선 textField의 tag를 이용하여 선택된 셀의 위치를 저장하였으나, Custom UITableViewCell을 만들 경우 delegate를 생성하여 Cell을 인자로 전달해주도록 하면 된다.

UITableViewCell의 이동좌표는 NSStringFromCGPoint와 NSLog를 이용하면 쉽게 확인가능하다.

UIScrollView의 contentInset, scrollIndicatorInsets 프로퍼티

UITableView와 Tab bar controller를 연동하다 UITableView의 마지막 셀이 탭바에 가려져 정상적으로 화면에 노출되지 않는 문제가 발생했다.  아래 그림을 보면 “1″ 이 적힌 셀이 탭바에 가려져있다. 스크롤을 하면 “1″이 보이긴 하지만, 스크롤을 놓으면 아래와 같다.

마지막 UITableViewCell 숨김

이 문제는 UIScrollView의 contentInset 프로퍼티 설정을 통해 해결 가능하다. UIScrollView의 contentInset과 scrollIndicatorInsets 속성에 대해 알아보자.

UIScrollView의 contentInset 속성은 contentSize에 버퍼 공간을 추가할 때 사용한다.이는 UIScrollView의 서브뷰 크기를 변경하지 않고 스크롤 뷰 크기를 확장할 수 있게 한다.  UIScrollView class reference에 기술된 contentSize 속성 설명이다.

contentInset property

아래 그림을 보자. contentSize 위쪽으로 64px 만큼, 아래로 44px 만큼 버퍼 공간을 추가한다. UIEdgeInsets를 이용하여 위, 아래 뿐 아니라 좌, 우에도 버퍼 추가 가능하다.

< from iOS developer library >

그림에서 위쪽으로 추가한 64px 은 navigation bar 높이 44px과 status bar 높이 20px의 합이며, 아래쪽의 44px은 toolbar 높이다. 네비게이션 바 또는 툴바 등에 UIScrollView의 컨텐츠가 가려지는 상황에서 위, 아래로 추가 공간을 확보함으로써 네비게이션 바와 툴 바 사이에 UIScrollView의 컨텐츠가 보이도록 하고 있다. (블로그 글 하단의 Reference를 확인하면 위 설정의 결과를 볼 수 있다.)

UITableView는 UIScrollView를 상속하므로 UIScrollView의 contentInset 속성에 접근 가능하다. (UITableView Class Reference)

본인이 접했던 문제는 화면 하단 탭바에 마지막 셀이 가려지는 것이었고, 스크롤뷰 아래쪽으로 44px 버퍼를 추가하여 스크롤 뷰 크기를 확장함으로써 해결하였다.


_tableView.contentInset = UIEdgeInsetsMake(0, 0, 44, 0);

위 코드로 개선된 화면은 다음과 같다. “1″이 정상적으로 보인다.

< contentInset 적용 후 >

UIScrollView의 contentInset 속성의 변경은 scroll indicator (스크롤 시 보이는 스크롤 표시) 에도 영향을 미친다.

Scroll indicator가 contentInset으로 추가한 버퍼 공간에도 표시가 된다. 이를 방지하기 위해서는 scrollIndicatorInsets 속성을 변경해야 한다. (화면 캡쳐가 어려워 적용 전/후 스크린 샷을 첨부하지 않는다. 간단한 코드로 테스트 가능하니 궁금하면 확인해보기 바란다. )

본인이 적용한 코드는 다음과 같다.


_tableView.contentInset = UIEdgeInsetsMake(0, 0, 44, 0);

// contentInset으로 추가한 버퍼만큼 scroll Indicator 표시 영역을 조정
_tableView.scrollIndicatorInsets = _tableView.contentInset;

* Reference : Scroll View Programming Guide for iOS 



				

UIView의 위치/크기 동적 변경 및 layoutSubviews 메소드 호출 시점

UIView 의 크기가 변경되면, 크기가 변경된 UIView 의 서브뷰들은 위치와 크기가 조정되어야 한다. UIView 는 이를 위해 자동과 수동으로 UIView 의 layout을 조정하는 방법을 제공한다.

다음의 이벤트에 레이아웃 변경이 발생한다.

  • UIView의 bounds 사이즈 변경 
  • root view의 변화를 유발하는 Interface orientation (세로모드, 가로모드 등)변화.
  • UIView의 view layer 변화 유발 또는 layout을 요청하는 Core Animation sublayers 의 설정
  • UIView의 setNeedsLayout 또는 layoutIfNeeded 메소드가 호출될 경우
  • UIView의 layer에서 setNeedsLayout 이 호출되 경우. (UIView는 Cora Animation Layer 인 CALayer 와 결합하여 화면에 컨텐츠/에니메이션을 표시. UIView 에는 layer 속성이 존재)

Autoresizing Rule을 이용한 자동 layout 변경

UIView의 autoresizesSubviews 속성값을 통해 서브뷰들의 layout을 변경할 수 있다.

속성값이 YES로 설정되어 있으면, UIView는 서브뷰들의 autoresizingMask 속성을 이용하여 서브뷰들의 위치와 크기를 변경시킨다. autoresizingMask 기본값은 UIViewAutoresizingNone으로 부모 UIView의 레이아웃 변경에 아무런 영향을 받지 않는다.

< from iOS developer library >

AutoresizingMask 속성에는 위 그림의 값을 설정할 수 있다. 각 요소는 OR 조건 (||) 으로 동시 적용이 가능하다. (각 상수에 대한 자세한 내용은 View Programming Guide for iOS 참고)

수동 Layout 변경

autoresizing rule을 통한 레이아웃 변경이 모든 서브뷰에 적용되면, UIKit은 UIView에 수동으로 조절할 수 있는 기회를 제공하는 의미에서 layoutSubviews 메소드를 호출한다.

만약 autoresizing rule 을 통한 서브뷰의 자동 레이아웃 변경이 의도한 바와 다르다면, layoutSubviews 메소드를 override 하여 추가적인 조정을 할 수 있다. (UIView를 상속하는 custom view를 만들어야 한다.)

layoutSubviews 내에서 다음과 같은 행위들이 가능하다.

  • 서브뷰의 size 및 position 변경
  • 서브뷰 또는 CALayer 의 추가/삭제
  • 서브뷰의 setNeedsDisplay 또는 setNeedsDisplayInRect: 메소드를 호출하여 서브뷰를 다시 그리도록 유도

아래 그림은 layoutSubviews 가 언제 호출되는지 실험한 데이터이다. (출처 :  when does layoutSubviews get called  )

< from Logic High Blog >

UIView 의 기준점만 변경될 때는 호출되지 않지만, 크기가 변경될 경우에는 호출된다. addSubview에서는 서브뷰와 부모뷰의 layoutSubview가 모두 호출된다. 이때 추가되는 서브뷰와 같은 레벨의 또다른 서브뷰가 존재하면, 그 서브뷰의 layoutSubviews도 호출된다.  UIScrollView 로 스크롤 할 때에도 layoutSubviews 메소드는 호출된다. 자세한 내용은 출처 블로그 내용을 참고하자.

본인도 별도로 간단한 테스트를 해봤다.

UIView 를 상속한 커스텀 클래스 UIView A 는 UIView 1 을 자식 요소로 같는다. UIView 1 은 UIView 2를 자식요소로 갖는다.  여러 케이스에서 UIView A 의 layoutSubviews가 호출되는지 확인하였다.

  • UIView A 원점만 변경 : 호출 안됨
  • UIView A 사이즈만 변경 : 호출됨
  • UIView 1 원점만 변경 : 호출안됨
  • UIView 1 사이즈만 변경 : 호출됨
  • UIView2 원점만 변경 : 호출안됨
  • UIView2 사이즈만 변경 : 호출안됨
  • UIViewA setNeedsLayout : 요청시마다 호출
  • UIViewA layoutIfNeeded : 호출 안됨. (변경점이 없으면 호출 안됨)
  • UIView 1 setNeedsLayout : 호출 안됨
  • UIView 2 setNeedsLayout : 호출안됨

본인의 테스트에 의하면 자식 요소의 사이즈 변화도 부모 요소의 layoutSubviews 메소드 호출을 유도한다. 직접적인 자식요소가 아닌 자식요소의 자식요소의 크기 변화는 부모 요소에 영향을 미치지 못한다. setNeedsLayout 메소드는 receiver 객체의 layoutSublayers 메소드를 호출한다.

* Reference : Adjusting the Size and Position of Views at Runtime in  View Programming Guide for iOS

Follow

Get every new post delivered to your Inbox.

Join 457 other followers

%d bloggers like this: