いものやま。

雑多な知識の寄せ集め

変種オセロのUIを作ってみた。(その13)

昨日はレイアウトのリデザインを行った。

今日はこのレイアウトを実現するための仕組みを実装する。

レイアウトマネージャ

SpriteKitでレイアウトを決めるときに必要になるのが、プロパティのpositionの値。
なので、各要素のpositionの適切な値を返すクラスを用意するといい。
これをレイアウトマネージャと呼ぶことにする。

レイアウトマネージャに必要な機能は、各要素の適切なpositionの値を返すというもの。
ただし、

  • positionの値は、その要素が追加されるノードの座標系での値である必要がある。
  • positionの値は、その要素の中心の位置である必要がある。

なので、それを考慮しないといけない。

ところで、各要素の中心の位置というのは、結構分かりにくい。
画面の左上を原点として、各要素の左上の位置を返す方が分かりやすい。
それと、各デバイスによってその位置も変わってくることに注意しないといけない。

そこで、以下のようにする。

  • レウアウトマネージャは、各要素の適切なpositionの値を返すインタフェースを持つ。
  • レイアウトマネージャの実体は、インタフェースとなる抽象クラスのサブクラスとし、デバイスごとに異なるものとする。
  • レイアウトマネージャの実体は、各要素の左上の位置、および、各要素のサイズを返す実装を持つ。
  • レイアウトマネージャのpositionの値を返すインタフェースは、各要素の左上の位置、各要素のサイズを返す実装から、適切なpositionの値を計算して返す。

うーん、言葉で書くのだと分かりにくいなぁ(^^;
実際にコードを見た方が分かりやすいかも。

LayoutManagerの実装

ということで、早速コードを。

//==============================
// YWF
//------------------------------
// LayoutManager.swift
//==============================

import SpriteKit

public class LayoutManager {
  public enum Component {
    case Scene
    case Background
    case Header
    case Logo
    case Board
    case ExitButton
    case UndoButton
    case PassButton
  }

  // 続く

とりあえず、要素の列挙型を定義。

インスタンスの取得

次に、レイアウトマネージャのインスタンスを取得するためのインタフェース。

  // 続き

  public class func getInstanceFor(size: CGSize) -> LayoutManager {
    switch size.height {
    case 920..<1020:
      return SmallestLayout(size: size)
    case 1020..<1080:
      return SmallLayout(size: size)
    case 1080..<1180:
      return MiddleLayout(size: size)
    case 1180..<1500:
      return LargeLayout(size: size)
    default:
      return LayoutManager(size: size)
    }
  }
  
  // 続く

引数で与えられたSceneのサイズに応じて、返す実体を変えている。

なお、各実体の境目となる値は、余白を除いて必要なサイズとなっている。
具体的には

実体 ヘッダ 相手情報 ボード 自分情報 合計
Smallest なし 80 760 80 920
Small 100 80 760 80 1020
Middle 100 80 760 140 1080
Large 140 140 760 140 1180

という感じ。

プロパティとイニシャライザ

次はプロパティとイニシャライザ。

  // 続き

  private let size: CGSize
  
  private init(size: CGSize) {
    self.size = size
  }

  // 続く

なお、インタフェースとなる抽象クラスが作られては困るので、イニシャライザの可視性はprivateとなっている。

positionを返すインタフェース

さて、レイアウトマネージャの肝となる、positionを返すインタフェース。

  // 続き

  public func getPosition(component: Component, relativeTo base: Component) -> CGPoint! {
    assert(
      component != .Scene,
      ".Scene is specified for component.")

    let point = self.getUpperLeftPosition(component)
    let size = self.getSize(component)
    if point == nil || size == nil {
      return nil
    }
    
    if base == .Scene {
      let x = point.x + size.width/2.0
      let y = self.size.height - point.y - size.height/2.0
      return CGPoint(x: x, y: y)
    } else {
      let basePoint = self.getUpperLeftPosition(base)
      let baseSize = self.getSize(base)
      if basePoint == nil || baseSize == nil {
        return nil
      } else {
        let x = (point.x + size.width/2.0) - (basePoint.x + baseSize.width/2.0)
        let y = -((point.y + size.height/2.0) - (basePoint.y + baseSize.height/2.0))
        return CGPoint(x: x, y: y)
      }
    }
  }

  // 続く

positionの値はその要素が追加されるノードの座標系での値である必要があるので、positionの値が欲しい要素だけじゃなくて、基準となる要素も引数で指定するようにしてある。

さて、肝心の計算の仕方。

まず、基準となる要素の中心の位置は、画面の左上を原点として、次のようになる。

(基準要素の中心のx座標) = (基準要素の左上のx座標) + (基準要素の幅)/2
(基準要素の中心のy座標) = (基準要素の左上のy座標) + (基準要素の高さ)/2

同様に、指定された要素の中心の位置は、画面の左上を原点として、次のようになる。

(指定要素の中心のx座標) = (指定要素の左上のx座標) + (指定要素の幅)/2
(指定要素の中心のy座標) = (指定要素の左上のy座標) + (指定要素の高さ)/2

ところで、基準となる要素の座標で、画面の左上の座標は次のようになる。
(基準となる要素の座標は、右上に行くほど値が大きくなることに注意)

(画面の左上のx座標) = - ( (基準要素の左上のx座標) + (基準座標の幅)/2 )
(画面の左上のy座標) = (基準要素の左上のy座標) + (基準座標の高さ)/2

したがって、基準となる要素の座標で、指定された要素の座標は、次のようになる。
(やはり、基準となる要素の座標は、右上に行くほど値が大きくなることに注意)

(指定要素の中心のx座標) = ( (指定要素の左上のx座標) + (指定要素の幅)/2 ) - ( (基準要素の左上のx座標) + (基準要素の幅)/2 )
(指定要素の中心のy座標) = - ( (指定要素の左上のy座標) + (指定要素の高さ)/2 ) + ( (基準要素の左上のy座標) + (基準座標の高さ)/2 )

ただし、基準となる要素がSceneの場合、ちょっと変わってくる。
というのも、Sceneの座標はノードの中心が原点ではなく、ノードの左下が原点となっているから。

ということで、画面の左上の座標が、Sceneの座標では次のようになる。

(画面の左上のx座標) = 0
(画面の左上のy座標) = (Sceneの高さ)

よって、基準となる要素がSceneの場合、指定された要素の座標は、次のようになる。

(指定要素の中心のx座標) = (指定要素の左上のx座標) + (指定要素の幅)/2
(指定要素の中心のy座標) = - ( (指定要素の左上のy座標) + (指定要素の高さ)/2 ) + (Sceneの高さ)

レイアウトマネージャの実体のためのインタフェース

positionの計算を可能にするために、各レイアウトマネージャの実体が実装しなければならないメソッドのインタフェースを用意する。

  // 続き

  private func getUpperLeftPosition(component: Component) -> CGPoint! {
    return nil
  }
  
  private func getSize(component: Component) -> CGSize! {
    switch component {
    case .Scene:
      return self.size
    case .Background:
      return CGSize(width: 828.0, height: 1472.0)
    case .Header:
      return CGSize(width: 828.0, height: 160.0)
    case .Logo:
      return CGSize(width: 260.0, height: 90.0)
    case .Board:
      return CGSize(width: 760.0, height: 760.0)
    case .ExitButton, .UndoButton, .PassButton:
      return CGSize(width: 120.0, height: 60.0)
    }
  }
  
  // 続く

といっても、各要素のサイズは(今のところ)どれも同じなので、ここで定義してしまっている。
必要ならサブクラスでオーバーライドする。

なお、これらのメソッドの可視性はprivateになっている。
ここらへんがSwiftの面白いところで、可視性はファイルベースなので、こんな指定が出来たりもする。

各レイアウトマネージャの実体の定義

レイアウトマネージャのサブクラスで実体を定義していく。

  // 続き

  // for iPhone 4S
  private class SmallestLayout: LayoutManager {
    private var userInfoSpaceHeight: CGFloat  // workaround to set using method. (actually constant)
    private let buttonMargin: CGFloat
    
    private override init(size: CGSize) {
      self.userInfoSpaceHeight = 0.0
      self.buttonMargin = 20.0
      super.init(size: size)
      self.userInfoSpaceHeight = (self.size.height - self.getSize(.Board).height)/2.0
    }
    
    private override func getUpperLeftPosition(component: Component) -> CGPoint! {
      switch component {
      case .Scene, .Background:
        return CGPoint(x: 0.0, y: 0.0)
      case .Header, .Logo:
        return nil
      case .Board:
        let x = (self.size.width - self.getSize(.Board).width)/2.0
        let y = self.userInfoSpaceHeight
        return CGPoint(x: x, y: y)
      case .ExitButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.ExitButton).width)
        let y = (self.userInfoSpaceHeight - self.getSize(.ExitButton).height)/2.0
        return CGPoint(x: x, y: y)
      case .UndoButton:
        let x = self.size.width - (self.buttonMargin*2.0 + self.getSize(.UndoButton).width + self.getSize(.PassButton).width)
        let y = self.userInfoSpaceHeight + self.getSize(.Board).height + (self.userInfoSpaceHeight - self.getSize(.UndoButton).height)/2.0
        return CGPoint(x: x, y: y)
      case .PassButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.PassButton).width)
        let y = self.userInfoSpaceHeight + self.getSize(.Board).height + (self.userInfoSpaceHeight - self.getSize(.PassButton).height)/2.0
        return CGPoint(x: x, y: y)
      }
    }
  }
  
  // for iPad
  private class SmallLayout: LayoutManager {
    private let userInfoSpaceHeight: CGFloat
    private var userInfoSpaceMargin: CGFloat  // workaround to set using method. (actually constant)
    private let headerHeight: CGFloat
    private let headerShadowHeight: CGFloat
    private let buttonMargin: CGFloat
    
    private override init(size: CGSize) {
      self.userInfoSpaceHeight = 80.0
      self.userInfoSpaceMargin = 0.0
      self.headerHeight = 100.0
      self.headerShadowHeight = 10.0
      self.buttonMargin = 20.0
      super.init(size: size)
      self.userInfoSpaceMargin = (self.size.height - self.headerHeight - self.getSize(.Board).height - self.userInfoSpaceHeight*2.0)/4.0
    }
    
    private override func getUpperLeftPosition(component: Component) -> CGPoint! {
      switch component {
      case .Scene, .Background:
        return CGPoint(x: 0.0, y: 0.0)
      case .Header:
        let offset = self.getSize(.Header).height - self.headerHeight - self.headerShadowHeight
        return CGPoint(x: 0.0, y: -offset)
      case .Logo:
        let margin = (self.headerHeight - self.getSize(.Logo).height)/2.0
        return CGPoint(x: margin, y: margin)
      case .Board:
        let x = (self.size.width - self.getSize(.Board).width)/2.0
        let y = self.headerHeight + self.userInfoSpaceMargin*2.0 + self.userInfoSpaceHeight
        return CGPoint(x: x, y: y)
      case .ExitButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.ExitButton).width)
        let y = (self.headerHeight - self.getSize(.ExitButton).height)/2.0
        return CGPoint(x: x, y: y)
      case .UndoButton:
        let x = self.size.width - (self.buttonMargin*2.0 + self.getSize(.UndoButton).width + self.getSize(.PassButton).width)
        let y = self.headerHeight + self.userInfoSpaceHeight + self.getSize(.Board).height + self.userInfoSpaceMargin*3.0 + (self.userInfoSpaceHeight - self.getSize(.UndoButton).height)/2.0
        return CGPoint(x: x, y: y)
      case .PassButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.PassButton).width)
        let y = self.headerHeight + self.userInfoSpaceHeight + self.getSize(.Board).height + self.userInfoSpaceMargin*3.0 + (self.userInfoSpaceHeight - self.getSize(.PassButton).height)/2.0
        return CGPoint(x: x, y: y)
      }
    }
  }
  
  // for iPhone 5 / 5s / 5c
  private class MiddleLayout: LayoutManager {
    private let userInfoSpaceHeight: CGFloat
    private let opponentUserInfoSpaceHeight: CGFloat
    private var userInfoSpaceMargin: CGFloat  // workaround to set using method. (actually constant)
    private let headerHeight: CGFloat
    private let headerShadowHeight: CGFloat
    private let buttonMargin: CGFloat
    
    private override init(size: CGSize) {
      self.userInfoSpaceHeight = 140.0
      self.opponentUserInfoSpaceHeight = 80.0
      self.userInfoSpaceMargin = 0.0
      self.headerHeight = 100.0
      self.headerShadowHeight = 10.0
      self.buttonMargin = 20.0
      super.init(size: size)
      self.userInfoSpaceMargin = (self.size.height - self.headerHeight - self.getSize(.Board).height - self.userInfoSpaceHeight - self.opponentUserInfoSpaceHeight)/4.0
    }
    
    private override func getUpperLeftPosition(component: Component) -> CGPoint! {
      switch component {
      case .Scene, .Background:
        return CGPoint(x: 0.0, y: 0.0)
      case .Header:
        let offset = self.getSize(.Header).height - self.headerHeight - self.headerShadowHeight
        return CGPoint(x: 0.0, y: -offset)
      case .Logo:
        let margin = (self.headerHeight - self.getSize(.Logo).height)/2.0
        return CGPoint(x: margin, y: margin)
      case .Board:
        let x = (self.size.width - self.getSize(.Board).width)/2.0
        let y = self.headerHeight + self.userInfoSpaceMargin*2.0 + self.opponentUserInfoSpaceHeight
        return CGPoint(x: x, y: y)
      case .ExitButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.ExitButton).width)
        let y = (self.headerHeight - self.getSize(.ExitButton).height)/2.0
        return CGPoint(x: x, y: y)
      case .UndoButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.UndoButton).width)
        let y = self.headerHeight + self.opponentUserInfoSpaceHeight + self.getSize(.Board).height + self.userInfoSpaceMargin*3.0
        return CGPoint(x: x, y: y)
      case .PassButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.PassButton).width)
        let y = self.headerHeight + self.opponentUserInfoSpaceHeight + self.getSize(.Board).height + self.userInfoSpaceMargin*3.0 + self.getSize(.UndoButton).height + self.buttonMargin
        return CGPoint(x: x, y: y)
      }
    }
  }
  
  // for iPhone 6 / 6 Plus
  private class LargeLayout: LayoutManager {
    private let userInfoSpaceHeight: CGFloat
    private var userInfoSpaceMargin: CGFloat  // workaround to set using method. (actually constant)
    private let headerHeight: CGFloat
    private let headerShadowHeight: CGFloat
    private let buttonMargin: CGFloat
    
    private override init(size: CGSize) {
      self.userInfoSpaceHeight = 140.0
      self.userInfoSpaceMargin = 0.0
      self.headerHeight = 140.0
      self.headerShadowHeight = 10.0
      self.buttonMargin = 20.0
      super.init(size: size)
      self.userInfoSpaceMargin = (self.size.height - self.headerHeight - self.getSize(.Board).height - self.userInfoSpaceHeight*2.0)/4.0
    }
    
    private override func getUpperLeftPosition(component: Component) -> CGPoint! {
      switch component {
      case .Scene, .Background:
        return CGPoint(x: 0.0, y: 0.0)
      case .Header:
        let offset = self.getSize(.Header).height - self.headerHeight - self.headerShadowHeight
        return CGPoint(x: 0.0, y: -offset)
      case .Logo:
        let margin = (self.headerHeight - self.getSize(.Logo).height)/2.0
        return CGPoint(x: margin, y: margin)
      case .Board:
        let x = (self.size.width - self.getSize(.Board).width)/2.0
        let y = self.headerHeight + self.userInfoSpaceMargin*2.0 + self.userInfoSpaceHeight
        return CGPoint(x: x, y: y)
      case .ExitButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.ExitButton).width)
        let y = (self.headerHeight - self.getSize(.ExitButton).height)/2.0
        return CGPoint(x: x, y: y)
      case .UndoButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.UndoButton).width)
        let y = self.headerHeight + self.userInfoSpaceHeight + self.getSize(.Board).height + self.userInfoSpaceMargin*3.0
        return CGPoint(x: x, y: y)
      case .PassButton:
        let x = self.size.width - (self.buttonMargin + self.getSize(.PassButton).width)
        let y = self.headerHeight + self.userInfoSpaceHeight + self.getSize(.Board).height + self.userInfoSpaceMargin*3.0 + self.getSize(.UndoButton).height + self.buttonMargin
        return CGPoint(x: x, y: y)
      }
    }
  }
}

長々とやってるけど、やってること自体は簡単。
各要素の左上の座標を返すメソッドを実装しているだけ。

一応、固定値で返してしまってもいいのだけど、読めば意味が分かるように、各座標をどう出しているのかを式で表すようにしてある。

レイアウトマネージャの動作確認

コントローラを書き換えて、動作を確認する。

//==============================
// YWF
//------------------------------
// GameViewController.swift
//==============================

import UIKit
import SpriteKit

class GameViewController: UIViewController {
  @IBOutlet weak var skView: SKView!
  
  override func viewDidLoad() {
    BoardNode.loadAssets()
    ButtonNode.loadAssets()
    PieceNode.loadAssetsAndCreateTemplates()
  }
  
  override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    
    var size = self.view.bounds.size
    if UIDevice.currentDevice().userInterfaceIdiom == .Phone {
      size.width *= 2.0
      size.height *= 2.0
    }
  
    let scene = GameScene(size: size)
    scene.scaleMode = .AspectFill
    
    let layoutManager = LayoutManager.getInstanceFor(size)
    
    let backgroundTexture = SKTexture(imageNamed: "Background")
    let background = SKSpriteNode(texture: backgroundTexture)
    background.position = layoutManager.getPosition(.Background, relativeTo: .Scene)
    scene.addChild(background)
    
    let headerPosition = layoutManager.getPosition(.Header, relativeTo: .Background)
    if headerPosition != nil {
      let headerTexture = SKTexture(imageNamed: "Header")
      let header = SKSpriteNode(texture: headerTexture)
      header.position = headerPosition
      background.addChild(header)
      
      let logoTexture = SKTexture(imageNamed: "Logo")
      let logo = SKSpriteNode(texture: logoTexture)
      logo.position = layoutManager.getPosition(.Logo, relativeTo: .Header)
      header.addChild(logo)
    }

    var board = Board()
    let boardNode = BoardNode(board: board)
    boardNode.position = layoutManager.getPosition(.Board, relativeTo: .Background)
    background.addChild(boardNode)
    boardNode.lightUpEnableSquares()

    let undoButton = ButtonNode(type: .Undo)
    let passButton = ButtonNode(type: .Pass, enabled: false)
    let exitButton = ButtonNode(type: .Exit)
    undoButton.position = layoutManager.getPosition(.UndoButton, relativeTo: .Background)
    passButton.position = layoutManager.getPosition(.PassButton, relativeTo: .Background)
    exitButton.position = layoutManager.getPosition(.ExitButton, relativeTo: .Background)
    background.addChild(undoButton)
    background.addChild(passButton)
    background.addChild(exitButton)
    
    self.skView.presentScene(scene)
  }
  
  // hide status bar.
  override func prefersStatusBarHidden() -> Bool {
    return true
  }
}

今まで割とベタに書いてた位置計算が、その内容をレイアウトマネージャに移したことで、割とシンプルなコードに。

そして、これを実行すると、次のようになる。

iPhone 4S
f:id:yamaimo0625:20150804215721p:plain

iPad
f:id:yamaimo0625:20150804215740p:plain

iPhone 5s
f:id:yamaimo0625:20150804220015p:plain

iPhone 6
f:id:yamaimo0625:20150804220041p:plain

iPhone 6 Plus
f:id:yamaimo0625:20150804220059p:plain

まだプレイヤーの情報を表示していないので、スキマがあるようにも感じるけど、いい感じなんじゃないかな。

今日はここまで!