ホームページ開発ツール>Xojo / Real Studio Trial and Error・CocoaのDeclareで環境設定パネルをOS標準っぽくしてみる(改訂版)

 Xojo / Real Studio Trial and Error

CocoaのDeclareで環境設定パネルをOS標準っぽくしてみる(改訂版)

目次
 はじめに

 以下は、Xojo Cocoaビルドについての話題です。

 環境設定のためのパネルを、OS添付のアップル製アプリケーションが用いている方法で使えるかどうか、調べてみました。

 なお検証には、Xojo 2016 Release 3を用いています。(Mac mini mid 2010 + OS X 10.12.6 Sierra)
注1)本件はパネルの表示に関してのものであり、値のセットや変更の反映等については対象としていません。
注2)アップルのガイドラインによると「Restore the last viewed preference pane.」なので、対応したところ、ソースコードの改変量が増えてしまったので、改訂版としました。

 参考サイト(3):Preferences - App Architecture - Human Interface Guidelines for macOS Apps

 方針

 アップル純正アプリの環境設定の特徴(の一つ)は、ページの内容によってサイズが変わり、その際にアニメーションを伴う点でしょう。
 確認できた限り、パネルの形式には二種類あるようです。
 殆どのアプリ(以下、標準)はウィンドウを使い、唯一(?)の例外がiTunesで、ダイアログを使っています。
前述のガイドラインでは、ウィンドウを使え、となっています。
 両者には、アニメーションの仕方にも違いがあります。目視で確認した限り、
 標準:まず、現在のページのコントロールが不可視になり、リサイズのアニメーションが実行された後、切り替わったページのコントロールとタイトルが表示される。
 iTunes:まず、ページが切り替わり、コントールとタイトルが表示された後、リサイズのアニメーションが実行される。

 アニーメーションの実装については、何種類かあるようですが、ウィンドウについては、以下のサイトがそのものなので大変参考になります。

 参考サイト(1):(旧) Cocoaの日々: 内容によってウィンドウのサイズを変える (2) NSViewAnimation

 一方ダイアログは、ウィンドウと同じでもいいのですが、こちらは終了通知を特に必要としないので、NSAnimationContextを使ってみることにしました。
 以上を踏まえ、それぞれの仕様は以下の通りとしました。

 例1(ウィンドウ形式)
 例2(ダイアログ形式)
 共通

 Xojoでの実装(例1)
【ソースコードのコピー&ペーストについて】
ソースコード(グレー背景部分の全文)をコピーし、指定のウィンドウ/クラスにペーストすると、(新規作成して名前等を個別にコピー&ペーストしなくても)復元されます。
ただし、この方法は、メソッドでは問題ないようですが、イベント/アクション/プロパティでは不安定?なので、ペーストできない場合は、各項目のカッコ内を適用して下さい。
  1. Xojoで新規プロジェクトを作成
  2. 以下をAppにペースト(できなければ、Function - Endの間をAppPrefsメニューハンドラに記述)
    Function AppPrefs() As Boolean
      Window2.Show
      
      Return True
    End Function
    
  3. MainMenuBar>編集メニューの最後に、項目(Name:AppPrefs、Super:PrefsMenuItem、Text:環境設定...)を追加
    注1)SuperがPrefsMenuItemであれば、どのメニューに追加してもOKなようです。
    注2)最後の文字に三点リーダを使うか、ピリオド3つ使うかについては、例えば以下のサイトが参考になります。
     参考サイト(2):ユーザインターフェイスの語句にこだわる - QiitaHuman Interface Guidelinesの項へジャンプ
  4. 新規ツールバーを作成(名前は、ここでは「Toolbar2」とした。)
  5. Toolbar2にツールアイテムを3個追加(Caption:項目1/項目2/項目3、Style:Toggle Button)
    注)アイコンは適当な画像を別途用意します。(なくても、アニメ機能の確認はできます。)
  6. 新規ウィンドウを作成(名前は、ここではデフォルトの「Window2」とした。)
  7. Window2にページパネルをドラッグし、Panelsの編集ボタンを押して、ページを追加(Page 0〜Page 3の4ページ)
  8. Window2にToolbar2をドラッグ(インスタンスの名前はToolbar21になる)
  9. 以下をToolbar21にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action(item As ToolItem) Handles Action
      // 現在選択中のボタンが押されたら、ページ移動せず、トグルもしない
      Dim idx As Integer
      select case item.Caption
      case "項目1"
        idx=1
      case "項目2"
        idx=2
      case "項目3"
        idx=3
      end select
      if idx=PanelValue then
        select case item.Caption
        case "項目1"
          me.ToolItem1.Pushed=true
        case "項目2"
          me.ToolItem2.Pushed=true
        case "項目3"
          me.ToolItem3.Pushed=true
        end select
        return
      end if
      
      // ページ移動(アニメあり)
      MovePage(item.Caption,true)
    End Sub
    
  10. 以下をWindow2にペースト(できなければ、Sub - Endの間をCloseイベントに記述)
    Sub Close() Handles Close
      // 現在のページ番号を保持
      PageNo=PagePanel1.Value
    End Sub
    
  11. 以下をWindow2にペースト(できなければ、Sub - Endの間をOpenイベントに記述)
    Sub Open() Handles Open
      // NSViewAnimeクラスのインスタンス生成
      viewAnime = new NSViewAnime(AddressOf animationDidEnd)
      
      // ページ番号に対応するツールアイテムを押下状態に
      PushButtonItem(PageNo)
      
      // ページ番号からキャプションを取得
      Dim capt As String
      select case PageNo
      case 1
        capt="項目1"
      case 2
        capt="項目2"
      case 3
        capt="項目3"
      end select
      
      // ページ移動(アニメなし)
      MovePage(capt,false)
    End Sub
    
  12. 以下をWindow2にペースト
    Private Sub animationDidEnd()
      // ページパネル切り替え
      PagePanel1.Value=PanelValue
      
      // タイトルをセット
      select case PanelValue
      case 1
        self.Title="項目1"
      case 2
        self.Title="項目2"
      case 3
        self.Title="項目3"
      end select
    End Sub
    
  13. 以下をWindow2にペースト
    Private Sub MovePage(capt As String, anime As Boolean)
      // ページ移動
      select case capt
      case "項目1"
        PagePanel1.Value=0
        PanelValue=1
        ResizeWindow(self,600,400,anime)
        
      case "項目2"
        PagePanel1.Value=0
        PanelValue=2
        ResizeWindow(self,600,200,anime)
        
      case "項目3"
        PagePanel1.Value=0
        PanelValue=3
        ResizeWindow(self,600,300,anime)
        
      end select
    End Sub
    
  14. 以下をWindow2にペースト
    Private Sub PushButtonItem(itemNo As Integer)
      // ページ番号に対応するツールアイテムを押下状態に
      select case itemNo
      case 1
        Toolbar21.ToolItem1.Pushed=true
      case 2
        Toolbar21.ToolItem2.Pushed=true
      case 3
        Toolbar21.ToolItem3.Pushed=true
      end select
    End Sub
    
  15. 以下をWindow2にペースト
    Private Sub ResizeWindow(win As Window, endW As Integer, endH As Integer, anime As Boolean)
      // アニメ判定
      if anime then
        viewAnime.DoViewAnime(win,endW,endH+78)  // 高さに78足しているのはツールバーの高さ(cocoaの処理にはXojoのツールバー高さが反映されないようだ)
        
      else
        win.Width=endW
        win.Height=endH
        animationDidEnd()  // アニメーション終了後に呼ばれる処理を直ちに行う
        
      end if
    End Sub
    
  16. 以下をWindow2にペースト(できなければプロパティに、名前:PanelValue、データ型:Integer、を追加)
    Private Property PanelValue as Integer
    
  17. 以下をWindow2にペースト(できなければプロパティに、名前:viewAnime、データ型:NSViewAnime、を追加)
    Private Property viewAnime as NSViewAnime
    
  18. 新規モジュールを作成(名前は、ここでは「Globals」とした。)
  19. 以下をGlobalsにペースト(できなければプロパティに、名前:PageNo、データ型:Integer、標準値:1、を追加)
    Public Property PageNo as Integer = 1
    
  20. 新規クラスを作成(名前は、ここでは「NSViewAnime」とした。)
  21. 以下をNSViewAnimeにペースト(できなければ移譲に、名前:ActionDelegate、を追加)
    Private Sub ActionDelegate()
    
  22. 以下をNSViewAnimeにペースト
    Public Sub Constructor(action As ActionDelegate)
      ActionHandler = action  // クラス生成元でActionを受け取るメソッドを登録
    End Sub
    
  23. 以下をNSViewAnimeにペースト
    Public Sub DoViewAnime(win As Window, endW As Integer, endH As Integer)
      // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
      
      // ウィンドウのポインタを取得するため、まずhandleからviewを取得して、その後window(ポインタ)を取得する
      Declare Function contentView Lib "Cocoa" selector "contentView" (class_id As Integer) As Ptr
      Dim pnt1 As Ptr = contentView(win.handle)
      Declare Function myWindow Lib "Cocoa" selector "window" (class_id As Ptr) As Ptr
      Dim winPtr As Ptr = myWindow(pnt1)
      
      Declare Function frame Lib "Cocoa" Selector "frame" (receiver As Ptr) As NSRect
      Dim startFrom As NSRect = frame(winPtr)
      
      Dim endAt As NSRect = NSMakeRect(startFrom.x, startFrom.y+(startFrom.h-endH), endW, endH)
      Dim duration As Double = 0.4  // Singleではダメ
      
      Dim dict As Ptr = NSClassFromString("NSMutableDictionary")
      Declare Function dictionary Lib "Cocoa" Selector "dictionary" (receiver As Ptr) As Ptr
      dict = dictionary(dict)
      
      Declare Sub setObject Lib "Cocoa" Selector "setObject:forKey:" (receiver As Ptr, obj As Ptr, key As CFStringRef)
      setObject(dict, winPtr, "NSViewAnimationTargetKey")
      
      Dim valu1 As Ptr = NSClassFromString("NSValue")
      Declare Function valueWithRect Lib "Cocoa" Selector "valueWithRect:" (receiver As Ptr, rect As NSRect) As Ptr
      valu1 = valueWithRect(valu1, startFrom)
      setObject(dict, valu1, "NSViewAnimationStartFrameKey")
      
      Dim valu2 As Ptr = NSClassFromString("NSValue")
      valu2 = valueWithRect(valu2, endAt)
      setObject(dict, valu2, "NSViewAnimationEndFrameKey")
      
      Dim ary As Ptr = NSClassFromString("NSArray")
      Declare Function arrayWithObject Lib "Cocoa" Selector "arrayWithObject:" (receiver As Ptr, obj As Ptr) As Ptr
      ary = arrayWithObject(ary, dict)
      
      Dim anim As Ptr = NSClassFromString("NSViewAnimation")
      Declare Function alloc Lib "Cocoa" Selector "alloc" (receiver As Ptr) As Ptr
      anim = alloc(anim)
      
      Declare Function initWithViewAnimations Lib "Cocoa" Selector "initWithViewAnimations:" (receiver As Ptr, obj As Ptr) As Ptr
      anim = initWithViewAnimations(anim, ary)
      
      Declare Sub setDuration Lib "Cocoa" Selector "setDuration:" (receiver As Ptr, value As Double)
      setDuration(anim, duration)
      
      Declare Sub setDelegate Lib "Cocoa" Selector "setDelegate:" (receiver As Ptr, id As Ptr)
      setDelegate(anim, makeDelegate())  // Delegateの設定
      
      Declare Sub startAnimation Lib "Cocoa" Selector "startAnimation" (receiver As Ptr)
      startAnimation(anim)
      
      Declare Sub release Lib "Cocoa" Selector "release" (receiver As Ptr)
      release(anim)
    End Sub
    
  24. 以下をNSViewAnimeにペースト
    Private Shared Sub animationDidEnd(id As Ptr, SEL As CString, animation As Ptr)
      ActionHandler.Invoke()  // クラス生成元でActionを受け取るメソッドを呼び出す
    End Sub
    
    注)SELの型をPtrとしていたが、CStringの方が相応しいので変更した。(未使用なので変更しなくても実害はなし。)(2018.07.30)
  25. 以下をNSViewAnimeにペースト
    Private Shared Function makeDelegate() as Ptr
      // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
      
      // Declare宣言
      Declare Function objc_allocateClassPair Lib "Cocoa" (superclass As Ptr, name As CString, extraBytes As Integer) as Ptr
      Declare Sub objc_registerClassPair Lib "Cocoa" (cls As Ptr)
      Declare Function class_addMethod Lib "Cocoa" (cls As Ptr, name As Ptr, imp As Ptr, types As CString) As Boolean
      
      // 既に作成済なら値を返す
      if delegateId<>nil then
        return delegateId
      end if
      
      // クラス名をmyNSViewAnimationDelegate(名前は任意。少なくとも今回のケースでは参照されない。)、メタクラス名をNSObjectにして、生成
      Dim newClassId As Ptr = objc_allocateClassPair(NSClassFromString("NSObject"), "myNSViewAnimationDelegate", 0)
      // ランタイムに登録(参照を可能とするため)
      objc_registerClassPair newClassId
      // Delegateの対象となるメソッドを追加(animationDidEnd:をXojo側で用意したanimationDidEndメソッドで受け取る。)
      if not class_addMethod (newClassId, NSSelectorFromString("animationDidEnd:"), AddressOf animationDidEnd, "v@:@@") then
        msgBox "error."
        return nil
      end if
      
      // 上記で生成したクラスのインスタンスを作成
      Declare Function alloc Lib "Cocoa" selector "alloc" (class_id As Ptr) As Ptr
      Declare Function init Lib "Cocoa" selector "init" (obj_id As Ptr) As Ptr
      delegateId = init(alloc(newClassId))
      
      // インスタンスを返す
      return delegateId
    End Function
    
  26. 以下をNSViewAnimeにペースト(できなければ共有プロパティに、名前:ActionHandler、データ型:ActionDelegate、を追加)
    Private Shared Property ActionHandler as ActionDelegate
    
  27. 以下をNSViewAnimeにペースト(できなければ共有プロパティに、名前:delegateId、データ型:Ptr、を追加)
    Private Shared Property delegateId as Ptr
    
  28. 他に、NSMakeRect(メソッド)、NSRect(構造体)が必要ですが、それらはmacoslibからコピーさせて頂きました。(上記Globalsにコピーする。)
 実行してみたところ、アニメーションが機能することを確認しました。


 Xojoでの実装(例2)
  1. Xojoで新規プロジェクトを作成
  2. 以下をAppにペースト(できなければ、Function - Endの間をAppPrefsメニューハンドラに記述)
    Function AppPrefs() As Boolean
      Window2.ShowModal
      
      Return True
    End Function
    
  3. MainMenuBar>編集メニューの最後に、項目(Name:AppPrefs、Super:PrefsMenuItem、Text:環境設定...)を追加
    注1)SuperがPrefsMenuItemであれば、どのメニューに追加してもOKなようです。
    注2)最後の文字に三点リーダを使うか、ピリオド3つ使うかについては、例えば以下のサイトが参考になります。
     参考サイト(2):ユーザインターフェイスの語句にこだわる - QiitaHuman Interface Guidelinesの項へジャンプ
  4. 新規ツールバーを作成(名前は、ここでは「Toolbar2」とした。)
  5. Toolbar2にツールアイテムを3個追加(Caption:項目1/項目2/項目3、Style:Toggle Button)
    注1)アイコンは適当な画像を別途用意します。(なくても、アニメ機能の確認はできます。)
    注2)ボタン群の両脇にFlexible Spaceを置くと、よりiTunesっぽくなります。
  6. 新規ウィンドウを作成(名前は、ここではデフォルトの「Window2」)し、TypeをMovable Modalにする。
  7. Window2にページパネルをドラッグし、Panelsの編集ボタンを押して、ページを追加(Page 0〜Page 3の4ページ)
    注)本方式では、空のページは不要なので、3ページでも可。(その場合は、コードを適宜変更してください。)
  8. Window2にデフォルトボタンとキャンセルボタンを(ページパネルの外に)置き、以下をペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action() Handles Action
      Hide
    End Sub
    
    注)ボタンが押された後の処理は、今回のテーマではないので省略。
  9. Window2にToolbar2をドラッグ(インスタンスの名前はToolbar21になる)
  10. 以下をToolbar21にペースト(できなければ、Sub - Endの間をActionイベントに記述)
    Sub Action(item As ToolItem) Handles Action
      // 現在選択中のボタンが押されたら、ページ移動せず、トグルもしない
      Dim idx As Integer
      select case item.Caption
      case "項目1"
        idx=1
      case "項目2"
        idx=2
      case "項目3"
        idx=3
      end select
      if idx=PagePanel1.Value then
        select case item.Caption
        case "項目1"
          me.ToolItem1.Pushed=true
        case "項目2"
          me.ToolItem2.Pushed=true
        case "項目3"
          me.ToolItem3.Pushed=true
        end select
        return
      end if
      
      // ページ移動(アニメあり)
      MovePage(item.Caption,true)
    End Sub
    
  11. 以下をWindow2にペースト(できなければ、Sub - Endの間をOpenイベントに記述)
    Sub Open() Handles Open
      // ページ番号に対応するツールアイテムを押下状態に
      PushButtonItem(PageNo)
      
      // ページ番号からキャプションを取得
      Dim capt As String
      select case PageNo
      case 1
        capt="項目1"
      case 2
        capt="項目2"
      case 3
        capt="項目3"
      end select
      
      // ページ移動(アニメなし)
      MovePage(capt,false)
    End Sub
    
  12. 新規モジュールを作成(名前は、ここでは「Globals」とした。)
  13. 以下をGlobalsにペースト
    Public Sub DoAnime(win As Window, endW As Integer, endH As Integer)
      
      // 文字列を指定してクラスオブジェクト/セレクタを取得する。最初に一回宣言しておけばよい。
      Declare Function NSClassFromString Lib "Cocoa" (aClassName As CFStringRef) As Ptr
      Declare Function NSSelectorFromString Lib "Cocoa" (aSelName As CFStringRef) As Ptr
      
      Declare Function contentView Lib "Cocoa" selector "contentView" (class_id As Integer) As Ptr
      Dim view As Ptr = contentView(win.handle)
      Declare Function frame Lib "Cocoa" Selector "frame" (receiver As Integer) As NSRect
      Dim frame1 As NSRect = frame(win.handle)
      
      Dim startFrom As NSRect = NSMakeRect(frame1.x, frame1.y, frame1.w, frame1.h)
      Dim endAt As NSRect = NSMakeRect(frame1.x, frame1.y+(frame1.h-endH), endW, endH)
      Dim duration As Double = 0.4  // Singleではダメ
      
      Declare Sub setFrameInt Lib "Cocoa" Selector "setFrame:display:" (receiver As Integer, frame As NSRect, disp As Boolean)
      setFrameInt(win.handle, startFrom, false)
      
      Dim cntx As Ptr = NSClassFromString("NSAnimationContext")
      Declare Sub beginGrouping Lib "Cocoa" Selector "beginGrouping" (receiver As Ptr)
      beginGrouping(cntx)
      
      Dim cntx2 As Ptr = NSClassFromString("NSAnimationContext")
      Declare Function currentContext Lib "Cocoa" Selector "currentContext" (receiver As Ptr) As Ptr
      cntx2 = currentContext(cntx2)
      
      Declare Sub setDuration Lib "Cocoa" Selector "setDuration:" (receiver As Ptr,durn As Double)
      setDuration(cntx2, duration)
      
      Declare Function animator Lib "Cocoa" Selector "animator" (receiver As Integer) As Ptr
      Dim anim As Ptr = animator(win.handle)
      
      Declare Sub setFramePtr Lib "Cocoa" Selector "setFrame:display:" (receiver As Ptr, frame As NSRect, disp As Boolean)
      setFramePtr(anim, endAt, true)
      
      Declare Sub endGrouping Lib "Cocoa" Selector "endGrouping" (receiver As Ptr)
      endGrouping(cntx)
      
    End Sub
    
  14. 以下をGlobalsにペースト(できなければプロパティに、名前:PageNo、データ型:Integer、標準値:1、を追加)
    Public Property PageNo as Integer = 1
    
  15. 他に、NSMakeRect(メソッド)、NSRect(構造体)が必要ですが、それらはmacoslibからコピーさせて頂きました。(上記Globalsにコピーする。)
 実行してみたところ、アニメーションが機能することを確認しました。


 おわりに

 アニメーションの滑らかさは特に問題ないと思われますが、ページやコントロールが増えたり、バックグラウンド処理の多いアプリなんかでのパフォーマンスは未知数です。

 あと、iTunesだけがダイアログなのは何故なんでしょうね。


 お世話になったサイト

 貴重な情報をご提供頂いている皆様に、お礼申し上げます。(以下、順不同)

 参考サイト(1):(旧) Cocoaの日々: 内容によってウィンドウのサイズを変える (2) NSViewAnimation
 参考サイト(2):ユーザインターフェイスの語句にこだわる - QiitaHuman Interface Guidelinesの項へジャンプ
 参考サイト(3):Preferences - App Architecture - Human Interface Guidelines for macOS Apps


 更新履歴

 2018.07.30 Xojoでの実装(例1)の24項を改訂
 2017.11.06 実装の項のソースコードを大幅に変更したので、改訂版とした。
 2017.11.06 はじめにと方針にガイドラインの説明を追加し、方針の「共通」の最後に項目を追加
 2017.10.27 新規作成


[Home]  [MacSoft]  [Donation]  [History]  [Privacy Policy]  [Affiliate Policy]