ObjecTips

Swift & Objective-C で iOS とか macOS とか

コピー可能な UILabel

編集不可な文字列を UILabel で表示させつつユーザにコピー操作だけはさせたいという事があったので実装を試みてみた。
コピーの方法は UITextViewUITextField でコピー操作を行う時と同様にポップアップのメニューを表示するようにする。
メニューは UIMenuController を使う事で標準UIのポップアップメニューを表示できる。

まず、ラベルをタップした時にメニューが表示される様にする。
UITapGestureRecognizer を使う方法もあるけど UILabel のサブクラスを作るので簡易に

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event

このあたりをオーバーライドしてタッチ操作の終わりを検出する事にする。

初期化

まず UILabel はデフォルトでは userInteractionEnabled = NO でタッチ操作を受け付けないので initWithFrame: をオーバーライドして初期化時にデフォルトで userInteractionEnabled = NO にしてタッチ操作を受け付ける様にしておく。

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        self.userInteractionEnabled = YES;
    }
    return self;
}

この時、別のイニシャライザの initWithCoder: はあえてオーバーライドしなくて良いと思う。
このラベルクラスを InterfaceBuilder (以下IB) で配置した際には initWithFrame: ではなくて initWithCoder: が呼ばれるが、ビュークラスはIB上で userInteractionEnabled のオンオフを切り替える事が出来るので initWithCoder: はあえてオーバーライドしない事でIBで設定した userInteractionEnabled の設定を引き継ぐ(変えない)様にする事が出来る。
IBで userInteractionEnabled = NO にすればコピーメニューが表示されなく YES にすればコピーメニューが表示されるラベルクラスになる。

タッチ操作のオーバーライドと firstResponder

UITextView UITextField の挙動を見ているとタップ操作でも長押し操作でも完了時にメニューを表示し、ビュー内でタッチを開始したらビューの外でタッチを離してもメニュー表示が発動するという挙動だったので特にタッチ位置の判定などはせずに以下の様にタッチの終わりだけを検出する。

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event
{
    [super touchesEnded:touches withEvent:event];
    [self becomeFirstResponder];
}

タッチを検出したら becomeFirstResponder を呼んで firstResponder になる様にして UIMenuController を表示する準備をする。
また UILabel はデフォルトでは firstResponder になる事が出来ないので canBecomeFirstResponder をオーバーライドして firstResponder になれる様にしておく。

- (BOOL)canBecomeFirstResponder
{
    return YES;
}
メニューの表示

メニューは表示位置を指定してから setMenuVisible:YES を呼ぶ。

    UIMenuController *menuController = [UIMenuController sharedMenuController];
    [menuController setTargetRect:self.bounds inView:self];
    [menuController setMenuVisible:YES animated:YES];

また、 - (BOOL)canPerformAction:(SEL)action withSender:(id)sender メソッドをオーバーライドしてどのメニュー項目に対応できるのかを実装しておく。
UILabel の場合はもともとメニュー表示に対応していないのでこのメソッドのオーバーライドを忘れるとメニューが表示されない。
今回はコピーメニューを表示したいので copy: メソッドを有効にして、他は super に投げておく。super には投げずに return NO; でも良い。

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    if (action == @selector(copy:)) {
        return YES;
    }
    return [super canPerformAction:action withSender:sender];
    // return NO;
}

ここまででひとまずラベルをタッチしてコピーメニューを表示する事ができる。

f:id:Koze:20160201085153p:plain

メニューの動作

今のままではコピーメニューを選択した時に unrecognized selector sent to instance でクラッシュしてしまうのでコピーメソッドを実装する。
コピーは単にペーストボードにラベルのテキストを渡してやればいい。

- (void)copy:(id)sender
{
    [UIPasteboard generalPasteboard].string = self.text;
}

一応ここまでで最低限は完成。
以下ではもうちょっと動きを良くしていく。

メニューの表示を改善

UITextView UITextField の挙動を見るとタッチ完了でメニューを表示、メニューが表示された状態でタッチ完了だとメニューを非表示という動きになっているのでこれを真似る。

    UIMenuController *menuController = [UIMenuController sharedMenuController];
    if (menuController.menuVisible) {
        [menuController setMenuVisible:NO animated:YES];
    }
    else {
        [menuController setTargetRect:self.bounds inView:self];
        [menuController setMenuVisible:YES animated:YES];
    }

これで同じ箇所をタッチする度にメニューが表示/非表示となり標準UIの動作に近づく。

メニュー表示位置の調整

今メニューに設定している targetRect だとラベルの全体の中心位置にメニューが表示されてしまう。
ラベルに背景色が付いていれば違和感は少ないかも知れないけど、ラベルの背景色がラベルの乗っているビューと同じ背景色だと変な箇所にメニューが表示されているように見える。

f:id:Koze:20160201085153p:plain f:id:Koze:20160201085219p:plain

これは targetRect を以下のように計算してやる事でテキストの表示位置に合わせてメニューを表示する事が出来るようになる。

        CGRect rect = [self textRectForBounds:self.bounds limitedToNumberOfLines:self.numberOfLines];
        [menuController setTargetRect:rect inView:self];

中央寄せや右寄せの alignment の設定を行った時にもバッチリ文字部分にメニューが表示される。

f:id:Koze:20160201085232p:plain f:id:Koze:20160201085237p:plain f:id:Koze:20160201085233p:plain f:id:Koze:20160201085238p:plain f:id:Koze:20160201085235p:plain f:id:Koze:20160201085239p:plain

共有メニュー

コピーできるんだったら共有メニューもついでに表示させたいと思うかもしれない。
共有メニューは _share: という Private メソッドの扱いのようだけど、一応対応する事は出来る。
まず対応するアクションの指定を以下のように変更

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
    if (action == @selector(copy:) ||
        action == @selector(_share:)) {
        return YES;
    }
    return [super canPerformAction:action withSender:sender];
}

そして _share: メソッドを実装する。
共有のアクションは UIActivityViewController を使ってシステムの挙動を呼び出すだけでOK。
UIActivityViewController は表示する ViewController が必要になるので self.window.rootViewController で辿ったり、はたまた UIApplicationDelegate 経由で辿ったりして ViewController を探して使うなどする。

- (void)_share:(id)sender
{
    UIActivityViewController *viewController = [[UIActivityViewController alloc] initWithActivityItems:@[self.text] applicationActivities:nil];
    [self.window.rootViewController presentViewController:viewController animated:YES completion:nil];
//    [[[UIApplication sharedApplication].delegate window].rootViewController presentViewController:viewController animated:YES completion:nil];
}

これで以下のようにシステムの共有メニューの表示と実行が可能になる。

f:id:Koze:20160201085349p:plain f:id:Koze:20160201085351p:plain

まとめ

コピー可能な UILabel