📲

【protocol】objc_util からrubicon-objc へ移行【delegate】

2024/04/22に公開

objc_util からrubicon-objc へ乗り換える

前回は、class宣言の方法について整理した。

rubicon-objcはobjc_utilよりも、Pythonの文法に準拠したPythonicなclass宣言。つまり、objc_utilのcreate_objc_classに依存した書き方は不要となる。

今回は、protocol(delegate)を中心に、コードの書き換えの整理する。

プロトコルを定義し、デリゲートメソッドを呼び出す

使う場面として:

Apple Developer DocumentationでProtocolと表記されている場合に使用。
UITableViewDataSourceのようにDelegateが名前に入っていない場合もある。まずは、(Apple)公式Documentationで調べることが大切。

objc_util の場合

classの宣言と同様に、create_objc_classで宣言。

以下のコードは、閉じるボタンを右肩に持たせたUINavigationControllerを実装した例(一部中略)。
uiモジュールのui.NavigationViewのようなものをイメージしている(uiモジュールを使わずに、Pythonista3上で独自にUIKitを呼び出す場面で使用)。

このコードで、プロトコル定義とデリゲートメソッドを呼び出方法を整理する。

image

objc_utilのsample.py
UINavigationController = ObjCClass('UINavigationController')

class NavigationController:

  def __init__(self):
    self._navigationController: UINavigationController

  def _override_navigationController(self):
    # --- `UINavigationController` Methods
    def doneButtonTapped_(_self, _cmd, _sender):
      """(自身の)アプリケーション終了
      `NavigationController` から生やした`done_btn` ボタンのアクション
      """
      this = ObjCInstance(_self)
      visibleViewController = this.visibleViewController()
      visibleViewController.dismissViewControllerAnimated_completion_(
        True, None)

    # --- `UINavigationController` set up
    _methods = [
      doneButtonTapped_,
    ]

    create_kwargs = {
      'name': '_nv',
      'superclass': UINavigationController,
      'methods': _methods,
    }
    _nv = create_objc_class(**create_kwargs)
    self._navigationController = _nv

  def create_navigationControllerDelegate(self):
    # --- `UINavigationControllerDelegate` Methods
    def navigationController_willShowViewController_animated_(
        _self, _cmd, _navigationController, _viewController, _animated):

      navigationController = ObjCInstance(_navigationController)
      viewController = ObjCInstance(_viewController)
      """
      長いので中略
      """
      done_btn = UIBarButtonItem.alloc(
      ).initWithBarButtonSystemItem_target_action_(0, navigationController,
                                                   sel('doneButtonTapped:'))
      visibleViewController = navigationController.visibleViewController()
      # --- navigationItem
      navigationItem = visibleViewController.navigationItem()
      navigationItem.rightBarButtonItem = done_btn

    # --- `UINavigationControllerDelegate` set up
    _methods = [
      navigationController_willShowViewController_animated_,
    ]
    _protocols = [
      'UINavigationControllerDelegate',
    ]

    create_kwargs = {
      'name': '_nvDelegate',
      'methods': _methods,
      'protocols': _protocols,
    }
    _nvDelegate = create_objc_class(**create_kwargs)
    return _nvDelegate.new()

  @on_main_thread
  def _init(self, vc: UIViewController):
    self._override_navigationController()
    nv = self._navigationController.alloc()
    nv.initWithRootViewController_(vc).autorelease()
    _delegate = self.create_navigationControllerDelegate()
    nv.setDelegate_(_delegate)
    return nv

  @classmethod
  def new(cls, vc: UIViewController) -> ObjCInstance:
    _cls = cls()
    return _cls._init(vc)

メソッドとする関数が点在する

クラスの下部にある、_init メソッドに注目してみる。

  • UINavigationController インスタンスを生成(nv
  • UINavigationControllerDelegate インスタンスを生成(_delegate
  • nv.setDelegate_(_delegate) で紐付け

create_objc_class にて、protocols の引数にdelegateを指定している。superclassprotocols を同時に引数を渡す事も可能ではある(多分)。

「多分」とした理由として、create_objc_class は関数を引数methods へ渡す関係上、各関数が点在してしまう。
事例コードの手法は、classで囲う事により点在するのを回避しているが、巻き上げ(ホスティング)の特性によりcreate_objc_class インスタンス生成の以前に関数を定義する。結果として、各関数とmethodsの関係が掴みにくい(気がしている)。

文字列でプロトコルを指定

ObjCClass で(もちろん)呼び出せないため、protocols の指定は、文字列で指定るすることになる。
typoが原因のエラーを検知する脳内リソースが必要となる。

rubicon-objc の場合

rubicon-objcのsample.py
UINavigationController = ObjCClass('UINavigationController')
UINavigationControllerDelegate = ObjCProtocol('UINavigationControllerDelegate')

class NavigationController(UINavigationController,
                           protocols=[UINavigationControllerDelegate]):

  @objc_method
  def viewDidLoad(self):
    send_super(__class__, self, 'viewDidLoad')
    """
    中略
    """
    self.delegate = self

  @objc_method
  def doneButtonTapped_(self, sender):
    visibleViewController = self.visibleViewController
    visibleViewController.dismissViewControllerAnimated_completion_(True, None)

  @objc_method
  def navigationController_willShowViewController_animated_(
      self, navigationController, viewController, animated: bool):
    viewController.setEdgesForExtendedLayout_(edgeNone)
    doneButton = UIBarButtonItem.alloc(
    ).initWithBarButtonSystemItem_target_action_(done, navigationController,
                                                 SEL('doneButtonTapped:'))
    visibleViewController = navigationController.visibleViewController

    navigationItem = visibleViewController.navigationItem
    navigationItem.rightBarButtonItem = doneButton

objc_utilと比較すると、回りくどい書き方が減りPythonicな表現となっている。

プロトコル宣言

ObjCProtocol にて、プロトコルを呼び出す。

Using and creating Objective-C protocols - Rubicon 0.4.8

ObjCProtocol | rubicon.objc.api — The high-level Rubicon API - Rubicon 0.4.8

objc_utilにはないclassで、typoエラーの発見が早期に見つけることができる。

class宣言.py
class NavigationController(UINavigationController,
                           protocols=[UINavigationControllerDelegate]):

class宣言時に、protocols へ配列として指定する。

インスタンスメソッド

(objc_utilも同様であるが)delegateが持っているメソッドをオーバーライドし処理を書いていく。
: とある部分は、_ へ書き換える。

navigationController:willShowViewController:animated: の場合。navigationController_willShowViewController_animated_ とする。

rubicon-objcでの注意点として、Pythonのアノテーション(型ヒント)が、標準のPythonより強い意味を持つ(プロトコル以前にrubicon-objc全体として)。

今回の場合は、引数animated:bool は必須。

documentation
- (void)navigationController:(UINavigationController *)navigationController 
      willShowViewController:(UIViewController *)viewController 
                    animated:(BOOL)animated;

(私自身C言語の理解が皆無なので、雰囲気理解でしかないが)* ポインタではない場合には、注意しつつ挙動を確認している。
ここの部分は、C言語を基礎としつつObjective-Cの理解を深めていきたい。
とにかく、実行エラーの場合には、アノテーションに注目してみる。

インスタンスメソッドのアノテーション.py
@objc_method
def navigationController_willShowViewController_animated_(
    self, navigationController, viewController, animated: bool):

self.delegate = self の嬉しさ

Pythonicな書き方ができるので、self が事実のObjective-Cのclassとなる。
参考とするコード(Objective-CやSwift)の形式と近い状態で書く事ができる。参照先と近しい状態を保持できる。

selfの嬉しさ.py
@objc_method
def viewDidLoad(self):
  send_super(__class__, self, 'viewDidLoad')
  """
  中略
  """
  self.delegate = self

さいごに

Pythonista3でRubicon-objcのみで、UIを出す。Pytoの挙動も確認。

PythonistaでRubicon-ObjCを使う

メインスレッドの処理については、上記記事を参考にしている。

(特にPytoでは)以下も(必要に応じ)参照。掲載のコードは、from rubicon.objc import 〜 としているが、以下参照時にはpyrubicon と読み替える必要あり。
【非推奨】Rubicon-ObjC の手動インストール・インポート

rubiconでUI.py
from rubicon.objc.api import ObjCClass, ObjCProtocol, objc_method
from rubicon.objc.runtime import SEL, send_super

import pdbr

ObjCClass.auto_rename = True

### --- onMainThread --- ###
from ctypes import byref, cast, Structure
import functools
from rubicon.objc.api import Block, ObjCClass, ObjCInstance
from rubicon.objc.runtime import libobjc, objc_block, objc_id

NSThread = ObjCClass('NSThread')


class struct_dispatch_queue_s(Structure):
  pass  # No _fields_, because this is an opaque structure.


_dispatch_main_q = struct_dispatch_queue_s.in_dll(libobjc, '_dispatch_main_q')


def dispatch_get_main_queue():
  return ObjCInstance(cast(byref(_dispatch_main_q), objc_id))


libobjc.dispatch_async.restype = None
libobjc.dispatch_async.argtypes = [objc_id, objc_block]


def onMainThread(func):

  @functools.wraps(func)
  def wrapper(*args, **kwargs):
    if NSThread.isMainThread:
      func(*args, **kwargs)
    block = Block(functools.partial(func, *args, **kwargs), None)
    libobjc.dispatch_async(dispatch_get_main_queue(), block)

  return wrapper
### --- ###

# --- UINavigationController
UINavigationController = ObjCClass('UINavigationController')
UINavigationControllerDelegate = ObjCProtocol('UINavigationControllerDelegate')
UINavigationBarAppearance = ObjCClass('UINavigationBarAppearance')
UIBarButtonItem = ObjCClass('UIBarButtonItem')

# --- UIViewController
UIViewController = ObjCClass('UIViewController')
UIColor = ObjCClass('UIColor')
UIButtonConfiguration = ObjCClass('UIButtonConfiguration')
UIButton = ObjCClass('UIButton')

NSLayoutConstraint = ObjCClass('NSLayoutConstraint')
# ref: [UIRectEdge | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uirectedge?language=objc)
'''
UIRectEdgeNone = 0
'''
edgeNone = 0

# ref: [UIControlEvents | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uicontrolevents?language=objc)
'''
UIControlEventTouchUpInside = 1 <<  6
'''
touchUpInside = 1 << 6

# ref: [UIBarButtonSystemItem | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uibarbuttonsystemitem?language=objc)
'''
UIBarButtonSystemItemDone
'''
done = 0


# --- NavigationController
class RootNavigationController(UINavigationController,
                               protocols=[UINavigationControllerDelegate]):

  @objc_method
  def viewDidLoad(self):
    send_super(__class__, self, 'viewDidLoad')
    appearance = UINavigationBarAppearance.new()
    appearance.configureWithDefaultBackground()

    navigationBar = self.navigationBar
    navigationBar.standardAppearance = appearance
    navigationBar.scrollEdgeAppearance = appearance
    navigationBar.compactAppearance = appearance
    navigationBar.compactScrollEdgeAppearance = appearance

    self.delegate = self

  @objc_method
  def doneButtonTapped_(self, sender):
    visibleViewController = self.visibleViewController
    visibleViewController.dismissViewControllerAnimated_completion_(True, None)

  @objc_method
  def navigationController_willShowViewController_animated_(
      self, navigationController, viewController, animated: bool):
    viewController.setEdgesForExtendedLayout_(edgeNone)
    doneButton = UIBarButtonItem.alloc(
    ).initWithBarButtonSystemItem_target_action_(done, navigationController,
                                                 SEL('doneButtonTapped:'))
    visibleViewController = navigationController.visibleViewController

    navigationItem = visibleViewController.navigationItem
    navigationItem.rightBarButtonItem = doneButton


# --- ViewController
class FirstViewController(UIViewController):

  @objc_method
  def onTap_(self, sender):
    svc = SecondViewController.new()
    navigationController = self.navigationController
    navigationController.pushViewController_animated_(svc, True)

  @objc_method
  def viewDidLoad(self):
    send_super(__class__, self, 'viewDidLoad')
    # --- Navigation
    self.navigationItem.title = 'FirstView'

    # --- View
    self.view.backgroundColor = UIColor.systemBlueColor()
    config = UIButtonConfiguration.tintedButtonConfiguration()
    config.title = 'Tap'
    config.baseBackgroundColor = UIColor.systemPinkColor()
    config.baseForegroundColor = UIColor.systemGreenColor()

    tapButton = UIButton.new()
    tapButton.configuration = config
    tapButton.addTarget_action_forControlEvents_(self, SEL('onTap:'),
                                                 touchUpInside)

    self.view.addSubview_(tapButton)
    # --- Layout
    tapButton.translatesAutoresizingMaskIntoConstraints = False
    NSLayoutConstraint.activateConstraints_([
      tapButton.centerXAnchor.constraintEqualToAnchor_(
        self.view.centerXAnchor),
      tapButton.centerYAnchor.constraintEqualToAnchor_(
        self.view.centerYAnchor),
      tapButton.widthAnchor.constraintEqualToAnchor_multiplier_(
        self.view.widthAnchor, 0.4),
      tapButton.heightAnchor.constraintEqualToAnchor_multiplier_(
        self.view.heightAnchor, 0.1),
    ])


class SecondViewController(UIViewController):

  @objc_method
  def onTap_(self, sender):
    navigationController = self.navigationController
    navigationController.popViewControllerAnimated_(True)

  @objc_method
  def viewDidLoad(self):
    send_super(__class__, self, 'viewDidLoad')
    # --- Navigation
    self.navigationItem.title = 'SecondView'

    # --- View
    self.view.backgroundColor = UIColor.systemGreenColor()
    config = UIButtonConfiguration.tintedButtonConfiguration()
    config.title = 'Tap'
    config.baseBackgroundColor = UIColor.systemPinkColor()
    config.baseForegroundColor = UIColor.systemBlueColor()

    tapButton = UIButton.new()
    tapButton.configuration = config
    tapButton.addTarget_action_forControlEvents_(self, SEL('onTap:'),
                                                 touchUpInside)

    self.view.addSubview_(tapButton)
    # --- Layout
    tapButton.translatesAutoresizingMaskIntoConstraints = False
    NSLayoutConstraint.activateConstraints_([
      tapButton.centerXAnchor.constraintEqualToAnchor_(
        self.view.centerXAnchor),
      tapButton.centerYAnchor.constraintEqualToAnchor_(
        self.view.centerYAnchor),
      tapButton.widthAnchor.constraintEqualToAnchor_multiplier_(
        self.view.widthAnchor, 0.4),
      tapButton.heightAnchor.constraintEqualToAnchor_multiplier_(
        self.view.heightAnchor, 0.1),
    ])


# --- present
@onMainThread
def present_viewController(myVC: UIViewController):
  app = ObjCClass('UIApplication').sharedApplication
  window = app.keyWindow if app.keyWindow else app.windows[0]
  rootVC = window.rootViewController

  while _presentedVC := rootVC.presentedViewController:
    rootVC = _presentedVC

  myNC = RootNavigationController.alloc().initWithRootViewController_(myVC)

  presentVC = myNC

  # ref: [UIModalPresentationStyle | Apple Developer Documentation](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle?language=objc)
  '''
  UIModalPresentationFullScreen = 0
  UIModalPresentationPageSheet = 1
  '''
  presentVC.setModalPresentationStyle_(1)

  rootVC.presentViewController_animated_completion_(presentVC, True, None)


if __name__ == '__main__':
  vc = FirstViewController.new()
  present_viewController(vc)

Discussion