m13o

2019-10-27 Sun 21:47
Storyboardもxibも使わずコードのみでCocoa Applicationを構築するprogramming macOS Cocoa

はじめに

Interface Builderは好きですか? 私は嫌いです. あれが直感的にGUIを作る道具だとはとても思えません. 思えないのであれで作りたくはありません. 自分でシリアライザ/デシリアライザを書いてコードでUIを作る方がマシと思える程に. というわけで, Interface Builderを使わずに, つまり, Storyboardもxib, nibも利用する事なく, Objective-CのコードのみでWindowやViewを生成し表示する方法を調べました.

なお, JetBrainsの信者になりつつある私のIDEはCLionなので, 必然的にcmakeを利用した構築方法になります(Bazel PluginがmacOSではこの記事の執筆時点ではまだちゃんと動かないのです……). また, Objective-C++じゃないと心が折れるので, 必然的にC++も利用する事になりますが, C++のバージョンは17です.

ViewController

まずは独自のViewを構築します. と言ってもViewをただ構築するだけなら簡単で, NSViewControllerを継承したViewController classを作成し, loadView をoverride, その中でViewを生成するコードを記述すれば良いだけです.

@implementation ViewController

- (void)loadView {
  self.view = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, 720, 480)];
  [self.view setWantsLayer:YES];
}

@end

AppDelegate

次に, NSApplicationのdelegateとして, NSObject<NSApplicationDelegate> を継承したAppDelegate classを作成し, NSApplicationのdelegateメソッドを実装します. とりあえずウィンドウを表示するだけなら, (void)applicationWillFinishLaunching:(NSNotification*)notification にNSWindowを生成するロジックを記述すれば良いです.

@interface AppDelegate ()
@property (nonatomic, strong) NSWindow *window; // MainWindow
@end

@implementation AppDelegate

- (void)applicationWillFinishLaunching:(NSNotification*)notification {
  self.window = [[[NSWindow alloc] initWithContentRect: NSMakeRect(0, 0, 0, 0)
                                             styleMask: NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable
                                               backing: NSBackingStoreBuffered
                                                 defer: NO]
                  autorelease];
  this.window.contentViewController = [ViewContoroller new];

  auto app_name = [[NSProcessInfo processInfo] processName];
  [self.windo setTitle: app_name];

  [self.window makeKeyAndOrderFront: self];
  [self.window orderFront:self];
  [self.window makeMainWindow];
}

@end

NSWindowのinitWithContentRectで与えているRectが0ですが, ViewController側でNSViewのサイズを決めているので, contentViewControllerにセットした時点でwindowのサイズはViewControllerで設定しているviewのサイズになります. NSWindowのmakeKeyAndOrderFrontは指定しておかないと, 終了時にSIGVでクラッシュします.

また, メインウィンドウを閉じたらアプリケーションを終了させたい場合は, (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender を以下のように実装します.

- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender {
  return YES;
}

MainMenu

上記でメインウィンドウを閉じると自動的にアプリケーションが終了するようにはなりますが, メニューに終了の項目がないとmacOSらしさがありません. そこで, AppDelegate classに以下のようなメニューを追加するメソッドを追加します. ここでは一先ずQuitするメニューのみを追加しています.

- (void)initializeMenu: (NSString *)app_name {
  auto menu = [[NSMenu new] autorelease];
  [[NSApplication sharedApplication] setMainMenu:menu];

  auto app_menu_item = [[NSMenuItem new] autorelease];
  [menu addItem: app_menu_item];
  auto sub_menu = [[NSMenu new] autorelease];

  auto quitMenuItem = [[[NSMenuItem alloc] initWithTitle:[@"Quit " stringByAppendingString:app_name]
                                                  action:@selector(terminate:)
                                           keyEquivalent:@"q"]
                        autorelease];
  [sub_menu addItem:quitMenuItem];
  [app_menu_item setSubmenu:sub_menu];
}

引数app_nameはQuitの項目に表示するためのアプリケーション名です. これはapplicationWillFinishLaunching内でNSProcessInfoから取得している物があるので, そのメソッドの中でinitializeMenuを呼び出してあげると良いでしょう.

main関数

main関数では, NSApplicationのインスタンスにAppDelegateを設定し, NSApplicationのメインループを起動します.

int
main([[maybe_unused]] int argc, [[maybe_unused]] const char* argv[])
{
  auto app = [NSApplication sharedApplication];
  app.delegate = [AppDelegate new];
  [app run];

  return 0;
}

cmakeの設定

ここまでで必要なコード片は揃ったので, CMakeLists.txtを記述してビルドできるようにします.

CMakeListx.txtにソースコードを追加

先に生成したコードを各々, ViewController.h/.mm, AppDelegate.h/.mm, main.mm とした場合に, 以下のようにadd_executableに追加します.

add_executable(${CMAKE_PROJECT_NAME}
  MACOSX_BUNDLE
  main.mm
  AppDelegate.mm
  AppDelegate.h
  ViewController.mm
  ViewController.h)

Frameworkの設定を追加

find_libraryを利用して, FoundationとCocoaのFrameworkの設定をCMakeLists.txtに記載します.

find_library(FOUNDATION
             Foundation)
find_library(COCOA
             Cocoa)

取得したFrameworkの情報をtarget_link_librariesに追記します.

target_link_libraries(${CMAKE_PROJECT_NAME}
  ${FOUNDATION}
  ${COCOA})

このアプリケーションの構造であれば, FoundationとCocoaだけがあれば十分です. 他のFrameworkが必要になった場合も同様の手順で追加します.

info.plistの設定

cmakeの場合 ${CMAKE_ROOT}/Modules/MacOSXBundleInfo.plist.in にinfo.plistのテンプレートがあります. これを直接参照してもいいのですが, そのまま利用すると高解像度なディスプレイで表示が滲んでしまうので, コピーを作り, 以下のkeyとvalueを追加します.

<key>NSHighResolutionCapable</key>
<true/>

そして, CMakeLists.txtにinfo.plistの設定を追加します.

set(APPLICATION_VERSION 1.0)
set(MACOSX_BUNDLE_EXECUTABLE "${CMAKE_PROJECT_NAME}")
set(MACOSX_BUNDLE_INFO_STRING "${CMAKE_PROJECT_NAME}")
set(MACOSX_BUNDLE_GUI_IDENTIFIER "com.example.${CMAKE_PROJECT_NAME}")
set(MACOSX_BUNDLE_LONG_VERSION_STRING "${CMAKE_PROJECT_NAME} Version ${APPLICATION_VERSION}")
set(MACOSX_BUNDLE_BUNDLE_NAME ${CMAKE_PROJECT_NAME})
set(MACOSX_BUNDLE_SHORT_VERSION_STRING ${APPLICATION_VERSION})
set(MACOSX_BUNDLE_BUNDLE_VERSION ${APPLICATION_VERSION})
set(MACOSX_BUNDLE_COPYRIGHT "Copyright 2019.")
set(MACOSX_BUNDLE_NSPRINCIPAL_CLASS "NSApplication")

set_target_properties(${CMAKE_PROJECT_NAME}
                      PROPERTIES MACOSX_BUNDLE_INFO_PLIST
                      ${CMAKE_SOURCE_DIR}/macOS/MacOSXBundleInfo.plist.in)

set_target_propertiesでMACOSX_BUNDLE_INFO_PLISTを設定する事により, 生成される.appにinfo.plistの情報が追加されるようになります.

ビルド & 実行

ビルドして生成された.appを実行すると無事ウィンドウが表示され, ウィンドウを閉じても, メニューのQuitをクリックしても, アプリケーションが終了すると思います.