20210321のiOSに関する記事は5件です。

ナビゲーションバーのカスタマイズ一覧(iOS)

はじめに

ナビゲーションバーをカスタマイズする方法を毎回忘れて実装に時間がかかるため、まとめました。

環境

  • OS:macOS Big Sur 11.1
  • Xcode:12.4 (12D4e)
  • Swift:5.3.2
  • iOS:14.4

ナビゲーションバーのカスタマイズ一覧

ナビゲーションバーのタイトルに画像をセットする

UINavigationItem.titleView に画像をセットすることで、ナビゲーションバーのタイトルを画像にできます。

MonsterIndexListViewController.swift
final class MonsterIndexListViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let imageView = UIImageView(image: UIImage(named: "navi_zukan"))
        imageView.contentMode = .scaleAspectFit
        self.navigationItem.titleView = imageView
    }

}
テキスト 画像
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 20.32.26.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 20.30.14.png

UIImageView.contentMode.scaleAspectFit にすると、画像の縦横比を変えずに表示できます。

ナビゲーションバーの戻るボタンの画像を変更する

UINavigationBarbackIndicatorImagebackIndicatorTransitionMaskImage を変更することで、戻るボタンの画像を変更できます。

MonsterIndexListViewController.swift
final class MonsterIndexListViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        let image = UIImage(named: "index_icon_back")
        self.navigationController?.navigationBar.backIndicatorImage = image
        self.navigationController?.navigationBar.backIndicatorTransitionMaskImage = image
    }

}
画像未変更 画像変更
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 19.09.43.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 19.34.20.png

セットした画像は以下であり、 tintColor が反映されてしまいます。
icon_back.png

tintColor を反映させない方法がわからなかったので、ご存じの方がいたら教えていただけると嬉しいです。

2021/03/23, 追記
コメント で教えていただきました。

対象画像のRender Asを Default から Original Image に変えると tintColor が反映されなくなります。
スクリーンショット_2021-03-23_19_40_17_after.png
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-23 at 19.39.01.png

ただ画像の位置が上に寄るのは直りません。
画像のサイズを調整すればかんたんに対応できます。
参考: https://sarunw.com/posts/how-to-change-back-button-image/#position

UIImage の位置を変える方法もありますが、 tintColor が反映されるのと、 UIImageView の位置は変わらないので見切れることがあるため、ベターな方法ではなさそうです。
参考: https://stackoverflow.com/questions/29445644/vertically-center-backindicatorimage-in-swift

ナビゲーションバーの戻るボタンのテキストを非表示にする

iOS 14以上の場合、 遷移元のビューコントローラー
UINavigationItem.backButtonDisplayMode.minimal にすることで、戻るボタンのテキストを非表示にできます。

MainViewController.swift
final class MainViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.navigationItem.backButtonDisplayMode = .minimal
    }

}
表示 非表示
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 19.09.03.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 19.09.43.png

戻るボタンの長押し時にビューコントローラーの title が表示されるので、戻るボタンを非表示にする場合でもセットするのが望ましいです。

MainViewController.swift
final class MainViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

+       self.title = "メイン"
        self.navigationItem.backButtonDisplayMode = .minimal
    }

}
タイトル未セット タイトルセット
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 19.17.05.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 19.11.50.png

戻るボタンのタイトルに半角スペースをセットする方法もよく見ますが、iOS 14以降では戻るボタン長押し時のメニューにタイトルが表示されなくなるので望ましくありません。

ナビゲーションバーの戻るボタンの色を変更する

UINavigationBartintColor を変更することで、戻るボタンの色を変更できます。

MonsterIndexListViewController.swift
final class MonsterIndexListViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.navigationBar.tintColor = UIColor(named: "tekimon_khaki")
    }

}
色未変更 色変更
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-23 at 20.18.10.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-23 at 20.21.45.png

一部のビューコントローラーのみナビゲーションバーの戻るボタンの色を変更する場合、戻し忘れに気をつけてください。

戻すには nil をセットします。

MainController.swift
final class MainViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.navigationBar.tintColor = nil
    }

}

ナビゲーションバーの背景色を変更する

UINavigationBarbarTintColor を変更することで、ナビゲーションバーの背景色を変更できます。

MonsterIndexListViewController.swift
final class MonsterIndexListViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.navigationBar.barTintColor = UIColor(named: "tekimon_khaki")
    }

}
背景色未変更 背景色変更
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-23 at 21.01.22.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-23 at 20.59.02.png

一部のビューコントローラーのみナビゲーションバーの背景色を変更する場合、戻し忘れに気をつけてください。

戻すには nil をセットします。

MainController.swift
final class MainViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.navigationBar.barTintColor = nil
    }

}

ナビゲーションバーを透過する

UINavigationBarbackgroundImageshadowImage を初期化することで、ナビゲーションバーを透過できます。

MonsterIndexListViewController.swift
final class MonsterIndexListViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
        self.navigationController?.navigationBar.shadowImage = UIImage()
    }

}
非透過 透過
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 20.39.05.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 20.36.22.png

一部のビューコントローラーのみナビゲーションバーを透過する場合、戻し忘れに気をつけてください。

非透過にするには nil をセットします。

MainViewController.swift
final class MainViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default)
        self.navigationController?.navigationBar.shadowImage = nil
    }

}

私が参考にしたサイトでは、 UINavigationBar に以下のような拡張メソッドを実装していました。
意図が明確になっていいと思います。

UINavigationBar+Transparency.swift
extension UINavigationBar {

    func enableTransparency() {
        setBackgroundImage(UIImage(), for: .default)
        self.shadowImage = UIImage()
    }

    func disableTransparency() {
        setBackgroundImage(nil, for: .default)
        self.shadowImage = nil
    }

}

ナビゲーションバーを非表示にする

UINavigationController.setNavigationBarHidden(_:animated:) メソッドを呼び出すことで、ナビゲーションバーの表示/非表示を切り替えられます。

MainViewController.swift
final class MainViewController: UIViewController {

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.navigationController?.setNavigationBarHidden(true, animated: false)
    }

}
表示 非表示
Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 18.38.41.png Simulator Screen Shot - iPhone SE (2nd generation) - 2021-03-21 at 18.35.47.png

ナビゲーションバーの表示/非表示が混在しているアプリの場合、画面を遷移するたびにナビゲーションバーの表示も切り替える必要があるので、 viewWillAppear(_:) メソッド内で実行すべきです。

ナビゲーションバーを表示 or 隠し忘れないように気をつけてください。

おまけ: スクリーンショットのアプリ

スクリーンショットに使ったアプリは、先日行われたHack Day 2021で私たちのチームが開発しました!

詳細は @hcrane14 さんがまとめているので、よかったら見てください。
https://note.com/hcrane/n/n98a8f1157390

おわりに

これでナビゲーションバーをカスタマイズする案件が来ても安心です :relaxed:

self.navigationController を変更すると他のビューコントローラーにも影響するので viewWillAppear(_:) で行って戻すのを忘れない、 self.navigationItem は変更しても他のビューコントローラーに影響しないので viewDidLoad() で行う、と覚えておくといいです(間違っていたらすみません)。

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

github actionsでunityビルドしてiOSとAndroidのストアに自動的に提出する

こんにちは。virapture株式会社もぐめっとです。

mogmet.jpg

この記事を書いてるときは冬なんですけど、もう自分の写真がなさすぎて夏までさかのぼってしまいました。

本日はCI/CD with Unity, GitHub Actions, and Fastlaneという記事を参考にUnityでの自動ビルドとストア提出を作ってみたのでそのメモ書きを残しておきます。

概要

UnityにはUnity Cloud Buildという公式のCIがあるのですが、いかんせんfastlaneとの連携ができないので、証明書を引っ張ってきたり、ストアにアップロードというのができません。

そこで探してみたところ、GameCIが出しているunity-builderというgithub actionsを使ってビルドを行い、デプロイにはfastlaneを使用することでうまくできました。

このunity-builderが結構優秀で、ライセンスの認証とかもやってくれる。そして、ビルドが終わった後はライセンスをリターンしてくれるという優れもの。
もはやUnity Cloud Build使わなくてもCIができちゃいます。

構築手順

github actions workflowの設置

記事の人のリポジトリのファイルをベースにしながら作りました。

ios,android以外にもmac, windowsなどもあったのですが、邪魔だったのでそのへんは抹消しました。

こんなワークフローになってます。

image.png

iOSのビルド長すぎぃぃ。。
お待ちかね実体ファイルはこんな感じになってます。

name: Test, Build, and Release CGS

on:
  # push: { branches: [master] } #masterにpushされたときにやりたい人はこちらをお使いください
  release: { types: [published] }

env:
  UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
  UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
  UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
  BUILD_NUMBER: ${{ github.run_number }}

jobs:
  tests:
    name: Test Code Quality
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: Cache Library
        uses: actions/cache@v2
        with:
          path: Library
          key: Library
      - name: Run EditMode and PlayMode Tests
        uses: game-ci/unity-test-runner@main
      - name: Publish Test Results
        if: ${{ always() }} # Avoid skipping on failed tests
        uses: davidmfinol/unity-test-publisher@main
        with:
          githubToken: ${{ secrets.GITHUB_TOKEN }}

  buildWithLinux:
    name: Build for ${{ matrix.targetPlatform }} by Unity
    runs-on: ubuntu-latest
    timeout-minutes: 90
    needs: tests
    strategy:
      fail-fast: false
      matrix:
        targetPlatform:
          - Android
          - iOS
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
          lfs: true
      - name: Cache Library
        uses: actions/cache@v2
        with:
          path: |
            Library
            build/${{ matrix.targetPlatform }}
          key: Library-${{ matrix.targetPlatform }}-
          restore-keys: Library-
      - name: Free Disk Space for Android
        if: matrix.targetPlatform == 'Android'
        run: |
          sudo swapoff -a
          sudo rm -f /swapfile
          sudo apt clean
          docker rmi $(docker image ls -aq)
          df -h
      - name: Build Unity Project
        uses: game-ci/unity-builder@main
        with:
          customParameters: -buildNumber ${{ github.run_number }}
          targetPlatform: ${{ matrix.targetPlatform }}
          buildMethod: Cgs.Editor.BuildCgs.BuildOptions
          androidAppBundle: true
          androidKeystoreName: keystore.keystore
          androidKeystoreBase64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
          androidKeystorePass: ${{ secrets.ANDROID_KEYSTORE_PASS }}
          androidKeyaliasName: ${{ secrets.ANDROID_KEYALIAS_NAME }}
          androidKeyaliasPass: ${{ secrets.ANDROID_KEYALIAS_PASS }}
      - name: Upload Build
        uses: actions/upload-artifact@v2
        if: github.event.ref != 'refs/heads/develop'
        with:
          name: cgs-${{ matrix.targetPlatform }}
          path: build/${{ matrix.targetPlatform }}

  releaseToGooglePlay:
    name: Release to the Google Play Store
    runs-on: ubuntu-latest
    timeout-minutes: 60
    needs: buildWithLinux
    if: github.event.action == 'published'
    env:
      GOOGLE_PLAY_KEY_FILE: ${{ secrets.GOOGLE_PLAY_KEY_FILE }}
      GOOGLE_PLAY_KEY_FILE_PATH: ${{ format('{0}/fastlane/api-finoldigital.json', github.workspace) }}
      ANDROID_BUILD_FILE_PATH: ${{ format('{0}/build/Android/Android.aab', github.workspace) }}
      ANDROID_PACKAGE_NAME: com.sample.app # 自分のアプリのパッケージ名に合わせよう!
      RELEASE_NOTES: ${{ github.event.release.body }}
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
      - name: Download Android Artifact
        uses: actions/download-artifact@v2
        with:
          name: cgs-Android
          path: build/Android
      - name: Prepare for Upload
        run: |
          echo "$GOOGLE_PLAY_KEY_FILE" > $GOOGLE_PLAY_KEY_FILE_PATH
          echo "$RELEASE_NOTES" > fastlane/metadata/android/en-US/changelogs/default.txt
      - name: Upload to Google Play
        uses: maierj/fastlane-action@v1.4.0
        with:
          lane: 'android playstore'

  buildIOS:
    name: Build Archive for iOS
    runs-on: macos-latest
    timeout-minutes: 60
    needs: buildWithLinux
    if: github.event.action == 'published'
    env:
      APPLE_CONNECT_EMAIL: ${{ secrets.APPLE_CONNECT_EMAIL }}
      APPLE_DEVELOPER_EMAIL: ${{ secrets.APPLE_DEVELOPER_EMAIL }}
      APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
      APPLE_TEAM_NAME: ${{ secrets.APPLE_TEAM_NAME }}
      APPLE_ITC_TEAM_ID: ${{ secrets.APPLE_ITC_TEAM_ID }}
      MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
      MATCH_PERSONAL_ACCESS_TOKEN: ${{ secrets.MATCH_PERSONAL_ACCESS_TOKEN }}
      IOS_APP_ID: com.sample.app # bundle idを指定します
      IOS_BUILD_PATH: ${{ format('{0}/build/iOS', github.workspace) }}
      PROJECT_NAME: onenitejinro
      RELEASE_NOTES: ${{ github.event.release.body }}
      MATCH_REPOSITORY_ACCOUNT: ${{ secrets.MATCH_REPOSITORY_ACCOUNT }}
      USYM_UPLOAD_AUTH_TOKEN: 'fake' # ビルドが途中でこけるのでfake用に環境変数を追加。
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
      - name: Cache restore for debug
        uses: actions/cache@v2
        with:
          path: |
            Library
            build/iOS
          key: Library-iOS-
          restore-keys: Library-
      - name: Download iOS Artifact
        uses: actions/download-artifact@v2
        with:
          name: cgs-iOS
          path: build/iOS
      - name: Cache restore cocoapods # firebase使ってるとcocoapodsを使うのですが、cocoapodsのキャッシュとらないと毎回時間かかるのでキャッシュしておきます。
        uses: actions/cache@v2
        if: ${{ always() }}
        with:
          path: |
            build/iOS/iOS/Pods
            ~/.cocoapods/repos
          key: Pods-${{ hashFiles('**/Podfile') }}
          restore-keys: Pods-
      - uses: actions/setup-ruby@v1
        with:
          ruby-version: '2.7.2'
      - name: Prepare for fastlane # GateKeeper対策
        run: |
          sudo spctl --master-disable
      - name: Archive iOS
        uses: maierj/fastlane-action@v2.0.1
        with:
          lane: 'ios build'
      - name: run if fail_step failed # ビルドがコケた原因がわかるようにcatしておきます
        if: failure()
        run: cat /Users/runner/Library/Logs/gym/*Unity-iPhone.log
      - name: Upload Build
        uses: actions/upload-artifact@v2
        if: github.event.ref != 'refs/heads/develop'
        with:
          name: ipa
          path: |
            ${{ github.workspace }}/*.ipa
            ${{ github.workspace }}/*.dSYM.zip

  releaseToAppStore:
    name: Release to the App Store
    runs-on: macos-latest
    timeout-minutes: 60
    needs: buildIOS
    if: github.event.action == 'published'
    env:
      APPLE_CONNECT_EMAIL: ${{ secrets.APPLE_CONNECT_EMAIL }}
      APPLE_DEVELOPER_EMAIL: ${{ secrets.APPLE_DEVELOPER_EMAIL }}
      APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
      APPLE_TEAM_NAME: ${{ secrets.APPLE_TEAM_NAME }}
      APPLE_ITC_TEAM_ID: ${{ secrets.APPLE_ITC_TEAM_ID }}
      FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
      FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
      MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
      MATCH_PERSONAL_ACCESS_TOKEN: ${{ secrets.MATCH_PERSONAL_ACCESS_TOKEN }}
      ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
      ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
      ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
      IOS_APP_ID: com.sample.app # bundle idを指定します
      IOS_BUILD_PATH: ${{ format('{0}/build/iOS', github.workspace) }}
      PROJECT_NAME: onenitejinro
      RELEASE_NOTES: ${{ github.event.release.body }}
      MATCH_REPOSITORY_ACCOUNT: ${{ secrets.MATCH_REPOSITORY_ACCOUNT }}
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2
      - name: Download iOS Artifact
        uses: actions/download-artifact@v2
        with:
          name: ipa
          path: |
            ${{ github.workspace }}/*.ipa
            ${{ github.workspace }}/*.dSYM.zip
      - name: Upload to the App Store
        uses: maierj/fastlane-action@v1.4.0
        with:
          lane: 'ios release'

このファイルのポイントを話しておくと、linuxでビルドが終わった後、fastlaneを使ってandroid/iosともにビルドもしくはアップロードを行っています。
iosは結構曲者で、UnityでCLIビルドが結構大変なので随所随所にビルドできるような仕組みを置いてます。

fastlaneの設置

fastlaneで使うファイルを参考ファイルをベースにカスタマイズした下記を設置しています。

keychain_name = "temporary_keychain"
keychain_password = SecureRandom.base64

platform :android do

  desc "Upload a new Android version to the Google Play Store"
  lane :playstore do
    upload_to_play_store(
      aab: "#{ENV['ANDROID_BUILD_FILE_PATH']}",
      track: 'internal',
      skip_upload_screenshots: true,
      skip_upload_images: true
    )
  end

end

platform :ios do

  desc "Push a new release build to the App Store"
  lane :release do
    api_key = app_store_connect_api_key(
      key_id: ENV['ASC_KEY_ID'], # your key id
      issuer_id: ENV['ASC_ISSUER_ID'], # your issuer id
      key_content: ENV['ASC_KEY_CONTENT'], # your secret key body
      # ex) key_content: '-----BEGIN PRIVATE KEY-----\nfoobar\n-----END PRIVATE KEY-----'
    )
    upload_to_app_store(
      api_key: api_key, # pass api_key
      force: true,
      skip_screenshots: true,
      skip_metadata: true
    )
  end

  desc "Submit a new Beta Build to Apple TestFlight"
  lane :beta do
    api_key = app_store_connect_api_key(
      key_id: ENV['ASC_KEY_ID'], # your key id
      issuer_id: ENV['ASC_ISSUER_ID'], # your issuer id
      key_content: ENV['ASC_KEY_CONTENT'], # your secret key body
      # ex) key_content: '-----BEGIN PRIVATE KEY-----\nfoobar\n-----END PRIVATE KEY-----'
    )
    upload_to_testflight(
      api_key: api_key, # pass api_key
      skip_waiting_for_build_processing: true
    )
  end

  desc "Create .ipa"
  lane :build do
    cocoapods(podfile: "#{ENV['IOS_BUILD_PATH']}/iOS/Podfile")
    disable_automatic_code_signing(path: "#{ENV['IOS_BUILD_PATH']}/iOS/Unity-iPhone.xcodeproj")
    certificates
    update_project_provisioning(
      xcodeproj: "#{ENV['IOS_BUILD_PATH']}/iOS/Unity-iPhone.xcodeproj",
      target_filter: "Unity-iPhone",
      profile: ENV["sigh_#{ENV['IOS_APP_ID']}_appstore_profile-path"], # より動的にみるようにしています
      code_signing_identity: "Apple Distribution: #{ENV['APPLE_TEAM_NAME']} (#{ENV['APPLE_TEAM_ID']})"
    )
    gym(
      workspace: "#{ENV['IOS_BUILD_PATH']}/iOS/Unity-iPhone.xcworkspace",
      scheme: "Unity-iPhone",
      clean: true,
      #clean: false,
      skip_profile_detection: true,
      codesigning_identity: "Apple Distribution: #{ENV['APPLE_TEAM_NAME']} (#{ENV['APPLE_TEAM_ID']})",
      export_method: "app-store",
      export_options: {
        method: "app-store",
        provisioningProfiles: {
          ENV["IOS_APP_ID"] => "match AppStore #{ENV['IOS_APP_ID']}"
        }
      }
    )
  end

  desc "Synchronize certificates"
  lane :certificates do
    cleanup_keychain
    create_keychain(
      name: keychain_name,
      password: keychain_password,
      default_keychain: true,
      lock_when_sleeps: true,
      timeout: 3600,
      unlock: true
    )
    match(
      type: "appstore",
      readonly: true,
      keychain_name: keychain_name,
      keychain_password: keychain_password
    )
  end

  lane :cleanup_keychain do
    if File.exist?(File.expand_path("~/Library/Keychains/#{keychain_name}-db"))
      delete_keychain(name: keychain_name)
    end
  end

  after_all do
    if File.exist?(File.expand_path("~/Library/Keychains/#{keychain_name}-db"))
      delete_keychain(name: keychain_name)
    end
  end
end

元ファイルからcocoapodsのインストールや、api keyまわりの設定、provisioning profileの指定方法などが若干違います。

また、問題を切り分けやすくするためにビルドとapp storeにリリースする処理はわけたりしています。

参考元ファイルでは現在、apikey周りは証明書をおいていい感じにやるようにしているみたいです。

他にfastlaneに付随するファイルも置いていきます

  • Appfile
for_platform :android do
  package_name(ENV["ANDROID_PACKAGE_NAME"])
  json_key_file(ENV["GOOGLE_PLAY_KEY_FILE_PATH"])
end

for_platform :ios do
  app_identifier(ENV["IOS_APP_ID"])

  apple_dev_portal_id(ENV["APPLE_DEVELOPER_EMAIL"])  # Apple Developer Account
  itunes_connect_id(ENV["APPLE_CONNECT_EMAIL"])     # App Store Connect Account

  team_id(ENV["APPLE_TEAM_ID"]) # Developer Portal Team ID
  itc_team_id(ENV["APPLE_ITC_TEAM_ID"]) # App Store Connect Team ID
end
  • Deliverfile
submit_for_review false
automatic_release true
force true
skip_screenshots true
release_notes({
  'default' => ENV["RELEASE_NOTES"],
  'en-US' => ENV["RELEASE_NOTES"]
})
run_precheck_before_submit false

submission_information({
  add_id_info_uses_idfa: false,
  export_compliance_compliance_required: false,
  export_compliance_encryption_updated: false,
  export_compliance_app_type: nil,
  export_compliance_uses_encryption: false,
  export_compliance_is_exempt: false,
  export_compliance_contains_third_party_cryptography: false,
  export_compliance_contains_proprietary_cryptography: false,
  export_compliance_available_on_french_store: false
});
  • Matchfile
git_url("https://github.com/iosのcertificateおいてるgitリポジトリ") # sshのurlではなく、httpsで指定してます。
git_basic_authorization(Base64.strict_encode64("#{ENV['MATCH_REPOSITORY_ACCOUNT']}:#{ENV['MATCH_PERSONAL_ACCESS_TOKEN']}"))
storage_mode("git")
type("appstore") # The default type, can be: appstore, adhoc, enterprise or development

app_identifier(["com.sample.app"]) # bundleIDを指定しよう
username("apple@example.com") # Your Apple Developer Portal username

projectの準備

BuildCgs.csをAssets/Scripts/Cgs/Editorディレクトリに置いておく。
このファイルがビルド番号の設定などをしてくれている。

もぐめっとの場合、CIのビルド番号とアプリのビルド番号を紐付けしたかったので冒頭部分だけbuildNumberを参照するように少し修正しました。

修正後BuildCgs.cs
            PlayerSettings.macOS.buildNumber = options["buildNumber"];
            PlayerSettings.iOS.buildNumber = options["buildNumber"];
            PlayerSettings.Android.bundleVersionCode = int.Parse(options["buildNumber"]);
            PlayerSettings.WSA.packageVersion = new Version(options["buildVersion"]);

metadataの準備

metadataがないとfastlane途中でこけるため準備しておく。

androidの準備

fastlane run download_from_play_store json_key:<json path>

jsonファイルはストアにアクセスするために必要なものになるが、こちらの記事に解説は委ねます。

iosの準備

fastlane deliver download_metadata

上記で生成されたファイルをコミットしておく

githubのsecrets設定

先術したymlやfastfileなどを見てもらったとおり、環境変数をふんだんに使うので、その準備をしていきます。

game-ci/unity-builder周りで使う環境変数

細かいことはGame CIのドキュメントをご参照ください

Unityのアカウント周りの情報は下記の変数になります

  • UNITY_EMAIL
  • UNITY_PASSWORD
  • UNITY_SERIAL

unityのserialについてはunityのwebサイトにログインして確認します。
スクリーンショット 2021-03-21 15.40.06.png

Androidの公開設定周りはこちらの変数になります

  • ANDROID_KEYALIAS_NAME
  • ANDROID_KEYALIAS_PASS
  • ANDROID_KEYSTORE_BASE64
  • ANDROID_KEYSTORE_PASS

上記四点はandroidの公開設定でも使うこの辺の情報ですね。
image.png

keystoreの作り方は公式に委ねます

fastlaneのアップロード周りで使ってる環境変数

アップロードするときなどに使われるfastlaneのDeliverで使われるAppfileで定義して使ってる一覧です。

  • APPLE_CONNECT_EMAIL

App Store Connect Accountにアクセスできるアカウントを指定します

  • APPLE_DEVELOPER_EMAIL

Apple Developer Accountにアクセスできるアカウントを指定します。

  • APPLE_TEAM_ID
  • APPLE_TEAM_NAME

上記2点はDeveloper Portalのサイトでアクセスできる項目を確認します

スクリーンショット 2021-03-21 16.00.14.png

  • APPLE_ITC_TEAM_ID

APPLE_TEAM_IDではなくて、app store connectで使うアカウントのほうのIDになるらしい。とりあえずfastlane使えるならspaceship使えば取得できます。

  • ASC_ISSUER_ID
  • ASC_KEY_CONTENT
  • ASC_KEY_ID

Appleの2段階認証を突破してアップロードできるようにする用の変数です。解説は下記記事に委ねます。

  • FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
  • FASTLANE_PASSWORD

先程のAppStoreConnect API Keyを使った方法でできると思うので、おそらくこの2点は設定しなくてもいいと思いますが、念の為設定しておきます。

設定方法はこちらに。

  • GOOGLE_PLAY_KEY_FILE

metadataをダウンロードする時お話した、jsonファイルの中身をはっつけます。

再掲)

fastlane match周りの環境変数

fastlane matchについてはこちらをご参照ください。証明書を管理するやつです。

  • MATCH_PASSWORD

matchに設定してるパスワードを指定します。

  • MATCH_PERSONAL_ACCESS_TOKEN
  • MATCH_REPOSITORY_ACCOUNT

上記2つはMatchfileで指定したgit_basic_authorizationに使うものなのですが、repositoryのcloneがgithub action上でできるようにするために使っています。
MATCH_PERSONAL_ACCESS_TOKENはgithubのaccess tokenを指定します。

MATCH_REPOSITORY_ACCOUNTはMatchのリポジトリにアクセスできるアカウントを指定します。(access tokenを発行したアカウント名ですね)

使い方

ようやく設定がおわりました。

このworkflowはreleaseを切ったときに初めて発火されます。

リポジトリの右側にあるreleasesから
image.png

Draft a new releaseボタンからリリースを作りましょう!
image.png

成功すればreleaseで書いた内容でappstoreとplay storeにリリースされるはずです!

まとめ

というわけでgame ciさんのunity-builderを使うことでgithub actionsでunityのCIを回すことができるようになりました!
unityはビルドに時間かかるので結構めんどくさかったのですがこの辺が省略できるようになるのはとても楽になりました。

みなさんの開発にあてる時間がこれで増えたら幸いです!

最後に、ワンナイト人狼オンラインというゲームを作ってます!よかったら遊んでね!
他にもcameconoffchaといったサービスも作ってるのでよかったら使ってね!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TCAでもUseCase/Repository/DataSourceが使いたい

はじめに

この内容は『iOSアプリ開発のためのFunctional Architecture情報共有会4』のための資料です。


導入

The Composable Architecture (TCA)では副作用実行にグローバルな関数1やClient、またはManagerというのが使われる。公式サンプルだけでなくpointfreeのゲームアプリのコードでもClientがよく出てくる。これを普段よく使われるUseCase/Repository/DataSourceを使いたいというのが発表の主旨。

名称未設定.004.png


TCAの副作用実行のための関数やClientについてのおさらい


(グローバルな)関数?

サンプルではグローバルな関数を使っていた。

次のコードはWeb API呼び出しで任意の数字からその数字にまつわるトリビアを返す非同期な処理。

func liveNumberFact(for n: Int) -> Effect<String, NumbersApiError> {
  return URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(n)/trivia")!)
    .map { data, _ in String(decoding: data, as: UTF8.self) }
    .catch { _ in
      // Sometimes numbersapi.com can be flakey, so if it ever fails we will just
      // default to a mock response.
      Just("\(n) is a good number Brent")
        .delay(for: 1, scheduler: DispatchQueue.main)
    }
    .setFailureType(to: NumbersApiError.self)
    .eraseToEffect()
}

関数利用側でEnvironmentによってDIする場合。Swiftにおいても、関数も変数として扱えるのでDIするときは(Parameter) -> Effectクロージャさえ合えばいい。

struct EffectsBasicsEnvironment {
  var numberFact: (Int) -> Effect<String, NumbersApiError>
}

これで結果を置き換えられるのでXcodeプレビューでも楽だし、テストコードも楽に書ける。

ただ、グローバルな関数として用意するのは複数人でアプリを作るときはあまりやりたくない。サンプルだから良いけども。


Client/Managerとは?

公式のサンプルではClient/Managerは次の通り。

  • Client系
    • WebSocketClient
    • SpeechClient
    • DownloadClient
    • AudioPlayerClient
    • WeatherClient
    • AudioRecorderClient
  • Manager系
    • LocationManager(これはサンプルというよりTCA公式のLocationラッパーかも)
    • MotionManager(これはサンプルというよりTCA公式のMotionラッパーかも)

ちなみにClientってどんなものなんだろう?


Clientにはvarクロージャが処理を返しロジックが入れ替えられるようになってる。

struct WeatherClient {
  var searchLocation: (String) -> Effect<[Location], Failure>
  var weather: (Int) -> Effect<LocationWeather, Failure>

  struct Failure: Error, Equatable {}
}
struct SearchEnvironment {
  var weatherClient: WeatherClient
  var mainQueue: AnySchedulerOf<DispatchQueue>
}

クロージャを変数で入れる場合にEnvironmentに直接入れるのではなく、型としてまとめている。


プロダクション用の実処理自体はextensionに.live定数で用意

extension WeatherClient {
  static let live = WeatherClient( // 初期化してクロージャに処理を用意してそれを定数 .live として返す
    searchLocation: { query in
      var components = URLComponents(string: "https://www.metaweather.com/api/location/search")!
      components.queryItems = [URLQueryItem(name: "query", value: query)]

      return URLSession.shared.dataTaskPublisher(for: components.url!) // URLSessionをDIしてない(する気がない
        .map { data, _ in data }
        .decode(type: [Location].self, decoder: jsonDecoder)
        .mapError { _ in Failure() }
        .eraseToEffect()
    },
    ...

短所

  • 処理がstaticな定数だから変数を参照することはできない
    • URLSessionをDIすることができない
      • WeatherClient自体を置き換えやすいが、WeatherClient自体をテストする気がない
        • 例えばDBで特定の要素が上書きされるロジックのテストとかはやる気がない

おそらくReducerの仕様さえテストできてれば良くて、そこから奥にあるミスっていうのはReducerの仕様に関するテストコードで気付こうという感じなのではないか。


短所を解決するには?

呼び出し時に引数で置き換えたいものを渡す

  // Reducer内
  case let .locationTapped(location):
    struct SearchWeatherId: Hashable {}

    state.locationWeatherRequestInFlight = location

    return environment.weatherClient
      .weather(location.id, /* ここでURLSessionを渡す */)

もしくは定数.liveのなかでFactory.getURLSession()みたいなのを用意してプリプロセッサ的なマクロで切り替えて取り出してもいい(が、その仕組みを自動化せず作るのは自分はあんまり...)。


ここまでのまとめ

  • pointfreeの人たちは細かな副作用の単体テストをReducerのテストでカバーしてる
    • 副作用での細かな処理のテストをやるには引数で渡すとか、Factory的なのを内部で切り替える

おまけ: 最近公開されたpointfreeのゲームのコードもClient

  • DictionaryClient
  • FeedbackGeneratorClient
  • FileClient
  • LocalDatabaseClient
  • LowPowerModeClient
  • RemoteNotificationsClient
  • ServerConfigClient
  • UIApplicationClient
  • UserDefaultsClient

Managerはない。


UseCase/Repository/DataSourceで副作用を呼び出す

Google I/O 2019 - 2021 Android Appから

image.png


層の解説

  • ドメイン層(いわくlightweight domain layer)
    • データレイヤーとプレゼンテーションレイヤーの間に位置
      • UIスレッドから離れた場所でビジネスロジックの個別部分を処理
    • UseCaseクラスを中心に構成されてる
    • コールバック地獄を避けるために、LiveData を使用してユースケースの結果を公開
      • 2020あたりではコルーチンも使われはじめる
      • 2018あたりまではexecuteNowメソッドで同期的なものは直接返したり
        • そもそもAndroid Blue Printという設計のお手本リポジトリがあった
  • データ層
    • リポジトリモジュールは、すべてのデータ操作を処理し、アプリの他の部分からデータソースを抽象化する役割を担う

データ層についての引用

私たちはFirestoreを気に入って使用していましたが、将来的に別のデータソースに交換したくなった場合、このアーキテクチャによってすっきりとした方法で交換することができます)


Google I/O 2019 - 2021 Android AppにおけるUseCase実装

...

/**
 * A [UseCase] that returns the [UserSession]s for a user.
 */
class LoadUserSessionOneShotUseCase @Inject constructor(
    private val userEventRepository: DefaultSessionAndUserEventRepository,
    @IoDispatcher dispatcher: CoroutineDispatcher
) : UseCase<Pair<String, SessionId>, UserSession>(dispatcher) {
    // suspendさせてコルーチンなメソッドだが
    override suspend fun execute(parameters: Pair<String, SessionId>): UserSession {
        val (userId, eventId) = parameters
        // 実処理は非同期ではなく同期処理
        return userEventRepository.getUserSession(userId, eventId)
    }
}
  • セッション情報を取得するUseCase
    • コルーチンで結果UserSessionを返してる
    • 処理はuserEventRepositorygetUserSessionメソッド
      • 同期も非同期もインタフェース揃えるためsuspend funでコルーチンだけど、同期的に処理2

SwiftでUseCaseを作る場合を考える


同期処理するSyncUseCaseの例

public protocol SyncUseCase where Failure: Error {
    associatedtype Parameters
    associatedtype Success
    associatedtype Failure

    func perform(_ parameters: Parameters) -> Result<Success, Failure>
}

このSyncUseCaseを直接利用する型でそのまま準拠してもそのまま使うことはできないので型消去するがその話は別に書いてる


ProfileをSaveするUseCaseを例に

public class SaveProfileUseCase: SyncUseCase {
    public typealias Parameters = Profile
    public typealias Success = ()
    public typealias Failure = Error

    private let dataSource: ProfileLocalDataSource

    init(dataSource: ProfileLocalDataSource) {
        self.dataSource = dataSource // initでdataSourceを入れ替えられるように
    }

    public func perform(_ parameters: Parameters) -> Result<Success, Failure> {
        dataSource.save(Profiles: parameters) // 実処理はdataSourceのCore Dataとかでやってる
    }
}

import ComposableArchitecture

extension SaveProfileListUseCase { 
    static func executeEffect(Profile: Parameters) -> Effect<Success, Failure> {
        .result { perform(profile) } // Effectにして結果を返す
    }
}

UseCase型の長所と短所

  • 長所
    • initでDataSourceを入れるので実行時に引数で用意するわけじゃない
      • 従来のプログラミングスタイルと同じ感じでできる
    • UseCase自体のテストコードを書ける(依存してるDataSourceを置き換えられるし)
  • 短所
    • 関数型のやり方に従来のオブジェクト指向型のプログラミングスタイルが混ざることの心理的抵抗感
    • UseCase型がめちゃくちゃ増える

UseCaseは使わないがRepository/DataSourceを使う


UseCase型を使わずに、しかしReducer以外の副作用の単体テストしたいのでRepository/DataSourceは使う場合を考える。

// プロフィールを変更して上書きする的なState, Action Environmentの集まり
enum ProfileSettingCore {
    enum State { ... }

    struct Action { ... }

    struct Environment {
        // DIできるようにする
        var save: (Profile, ProfileDataSource) -> Result<(), Error> 
    }

    static let reducer = ...
}

ProfileをSaveする例

// プロフィールを変更して上書きする的なState, Action Environmentの集まり
enum ProfileSettingCore {
    enum State { ... }    
    struct Action { ... }

    struct Environment {
        // DIできるようにする
        var save: (Profile, ProfileDataSource) -> Result<(), Error> 
    }

    static let reducer = ...
}

extension ProfileSettingCore {
    enum SideEffect {
        static func save(profile: Profile, dataSource: ProfileDataSource) -> Result<(), Error> {
            dataSource.update(profile) // 実質的なUseCaseの処理
        }
    }
}

UseCaseを使わないSideEffectでグルーピングされたstatic関数の長所と短所

  • 長所
    • シンプル(UseCase型の面倒な型消去なんて考えなくていい)
    • SideEffect自体のテストコードを書ける(依存してるDataSourceを置き換えられるし)
  • 短所
    • 副作用実行の引数で依存するRepository/DataSourceを入れるのはかったるい

まとめ

  • 何がベストなのかはチームにあってるやり方を採用したらいい
  • pointfree自体のやり方でClientをつくるってのもそれがチームに合っていればそれでいい

  1. もちろんグローバルである必要はなくサンプルだからグローバルな関数になってるだけだとは思う。 

  2. 他にもインタフェースはあり、invokeとかあり、むかしはexecuteNow, performなどがあった。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swift初心者がアプリを作れるようになるまで①

開発環境

MacBook Air (2020)
macOS Big Sur Ver11.2.2
Xcode Ver12.4(12D4e)

Swiftってなに?

image.png

Swiftはappleが開発したオープンソースの言語で、iOS、Mac、Apple TV、Apple Watch向けアプリの開発に用います。

Swift。誰もが圧倒的に優れたアプリを作れる、パワフルなオープンソースの言語です。

iOS、Mac、Apple TV、Apple Watch向けのアプリを開発するためにAppleが作った、
強固で直感的なプログラミング言語。それがSwiftです。デベロッパのみなさんに、かつてないほどの
自由を届けられるように設計されています。Swiftは簡単に使えて、しかもオープンソースなので、
アイデアがある人なら誰でも、画期的なアプリを作ることができます。
(apple公式HPより引用)

おおまかに言えばiPhoneのアプリ開発ができる言語ということですね。

iPhoneアプリの開発はかつてはObject-Cがメインでしたが、Swift及びXcode(Swiftを扱えるエディタ)がリリースされてからはSwiftがメインとなっています。

理由としてはSwiftで作ったアプリは、一般的な検索アルゴリズムがこれまでよりも大幅に速く実行できるからなんです。その速さはなんとObject-Cの最大2.6倍、Python2.7の最大8.4倍と驚きの速度。(apple公式HPより引用)

ちなみにSwiftに日本語版は無いので、英語が苦手な方は翻訳を駆使して頑張ってください。

Swiftを扱うのに必要なもの

Swiftでアプリを制作するには、以下の環境が必要となってきます。
・Mac
・Xcode
・AppleID
そう、これだけです。厳密にはリリースに際してAppleディベロッパプログラムへの登録などが必要になるのですが、今回のテーマは「Swift初心者がアプリを作れるようになるまで」ということで、リリース方法はまた別で紹介しようと思います。ひとまず作るだけならMacとXcodeがあればOKです。(Xcodeが無い方はこちらからインストールしてください)

Windowsで開発できないの?

気になりますよね、これ。結論から言うと...可能です。ですが2021/3/6現在Swiftパッケージマネージャと、REPLやデバッグエクスペリエンスに使用するlldbの開発が完了しておらず、とりあえずはなにも考えずにMacでの開発をおすすめします。
どんどん情報は更新されているので、気になる方は「Swift Windows」で検索してみてください。

Xcodeの設定を知ろう

image.png
Xcodeを開くとこのような画面が出てきます。それぞれの説明をすると、

項目 説明
Create a new Xcode project 新規プロジェクト作成
Clone an existing project 既存プロジェクトのクローン
Open a project or file 既存プロジェクトのオープン

...そのまんまですね。
ちなみに下のチェックボックス「Show this window when Xcode launches」のチェックを外すと次回からこのメニュー画面が表示されなくなるので、ひとまず触らなくてOkです。

今回は新規作成なのでもちろん「Create a new Xcode project」(赤枠)を選択します。
スクリーンショット 2021-03-08 14.36.56.png
するとこちらの画面になるはずなので、iOSタブの「App」を選択します。
他にもたくさんアイコンが並んでいると思いますが、これらは最初に組まれる雛形が違うだけでいくらでもカスタマイズができるので、あまり難しく考えずに自身が作りたいものに最も近いものを選べばいいと思います。
今回はひとまず「App」で進めていきます。
スクリーンショット 2021-03-08 14.41.46.png
すると次はこちらの画面になるので、「Product Name」に今回のプロダクト名を入力します。今回はとりあえず「Sample」としておきます。
以下、他の項目については以下のような感じです。

項目 説明
Product Name プロダクト名、アプリ名
Team プロダクトを作成する団体名(もしくは個人名)
Organization Identifier プロダクトを作成する団体のID(もしくは個人)
Bundle Identifier 今回のID(Organization Identifier + . + Product Name)
Interface 実装方法の設定(SwiftUI, StoryBoardから選択)
Life Cycle ライフサイクルの選択
Languages 使用するプログラミング言語(Swift、 Objective-Cから選択)

上半分に関しては自身で設定すれば良いのですが、下部に関しては少し知識が必要かもしれません。

InterFaceについて

SwiftのインターフェイスはSwiftUI, StoryBoardの2つとなっていますが、これらの違いはコードでUIを実装するか、GUIでUIを実装するか。といったところです。
以前まではStoryBoard, Xib, UIKitの3つが存在していたのですが、iOS13にてSwiftUIが追加されました。
どれを選択するかは一概には言えず、アプリの特徴や開発者の慣れなどで考えるのがいいと思います。
ひとまず今回はサンプルを動かすだけなので、何も考えずにSwiftUIを選択します。

Life Cycleについて

Life Cycle(ライフサイクル)ってなんだ...わかります。その気持ち。知ってた方は飛ばしてちゃってください。
私もSwift学習を開始してから知ったので、知らなくても全く問題ないです。
ライフサイクルはアプリ内の状態遷移を表す言葉です。...どゆこと。
実際にiPhoneでアプリを使用するときを想像してみてください。
1.アイコンをタップする
2.画面が起動画面に切り替わる
3.画面がアプリ画面に切り替わる
4.画面のみ閉じるとアプリがバックグラウンドへ
5.再度起動すると前回使用画面から起動
6.タスクキルでアプリ終了
こういった一連の流れでアプリ側が保持している状態をライフサイクルといいます。
だいぶ説明を省いているので詳しく知りたい方は「アプリ ライフサイクル」で検索してみてください。

今回は「SwiftUI App」でOKです。

Languageについて

SwiftとObjectiv-Cからの選択です。(上記InterFaceでSwiftUIを選択している場合Swiftのみ選択可能)
こちらは使用言語についてです。
SwiftとObjective-Cの選択に関しては、一概には言えませんがSwiftの方が新しいことや、Swiftの方がAppleがSwiftを推奨していることからざっくりとSwiftの方が良いとされています。
ちゃんとした理由を知りたい方はご自身で調べることをおすすめします。

下部の選択項目について

上記の項目の下に写真のような選択項目があるのですが、これらに関しては一応軽い説明を載せますが、今回は全て選択しなくて大丈夫です。

Use Core Data / Host in CloudKit

「Use Core Data」には名前の通りCore Dataというものを使用するかどうかの選択です。
Core DataというのはざっくりXcode上からデータベース構造の設定を行えたり、使用するデータの保存、削除、更新を行うプログラムを書くための仕組みのことです。
こちらに関しても詳細はご自身で調べてください。(ちゃんと説明するとそれだけで1記事分になってしまうので...)
「Host in CloudKit」に関してですが、これはCloudKitを理解することから始まります。
簡単に言えばiCloudにデータを保存できるサービスです。AppleサポートのCloudKitのページを見てみると、CloudKitを使用するとキー値データ、構造化データ、および各種アセットをiCloudに保存できるようです。そして公開データベースと非公開データベースの両方に対応しているので、プライベートなデータ、パブリックなデータ両方扱えることになります。例えばAppleIDを使用してプライベートなデータを管理することも可能です。これはiOS標準アプリの「メモ」などにも使用されており、iPhoneでメモに何か保存すると、AppleIDで同期されているmacなどのメモにも表示されるようになります。これはCloudKitによって管理されていたんです。

Include Tests

続いて「Include Tests」についてです。これに関しては単純で、チェックを入れることでテスト用のターゲットとテスト用のコードが生成されます。なのでとりあえずはチェックを入れないで大丈夫です。

これですべての設定を終えたので、右下のNextボタンを押して次に進みます。

まとめ

ひとまず今回はここまでです。
xCodeは日本語版が無いということで設定内容を理解することだけで一苦労ですね...。
次回からはサンプルコードを使用してエディター内の項目解説に移るので、更新しましたらぜひ「Swift初心者がアプリを作れるようになるまで②」もご覧ください。最後までお読みいただきありがとうございました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIでフルスクリーンのモーダルを表示する(iOS13)

SwiftUI(iOS13)でフルスクリーンモーダルを表示する

久しぶりの記事になります。おはこんばんにちは和尚です!
今日は一昨年Appleから発表されて話題となっているSwiftUIについての記事を書いていきたいと思います:v:

SwiftUI 2はiOS13では使用できない件

現在(2021.3月時点)ではSwiftUIはver.2までリリースされておりますが、残念なことにSwiftUI 2でリリースされた機能はiOS13で使用することができません。SwiftUI 1では一般的なアプリに必要な機能が全然揃っておらず、2以上にライブラリやUIKitに頼ることになります。

必須級ライブラリ
SwiftUIX
↑ SwiftUIにまだ実装されていないものを補ってくれます。説明書がないものがチラホラあるのが残念...

さて、本題であるフルスクリーンのモーダルですが、公式からは「.fullScreenCover」というものが用意されていますがこちらはSwiftUI 2から登場したものとなっており残念ながらiOS13では使用することができません。
ので、今回iOS13でも使えるフルスクリーンのモーダルを作っていきましょう!

※ちなみに筆者、初めてのiOSアプリ実装がSwiftUIとなっておりUIKitも現在絶賛勉強中ですのでお手柔らかに(笑)

フルスクリーンのモーダル実装

1. UIApplicationの拡張

UIApplicationを拡張して一番上のコントローラーを取得するメソッドと、フルスクリーンのモーダルを閉じるメソッドを作成していきます。

UIApplication+Extension.swift
extension UIApplication {
    /// 一番上にあるコントローラーを取得する
    public func getTopViewController() -> UIViewController? {
        guard let window = UIApplication.shared
                .connectedScenes
                .filter({$0.activationState == .foregroundActive})
                .map({$0 as? UIWindowScene})
                .compactMap({$0})
                .first?.windows.first else {
            return nil
        }

        window.makeKeyAndVisible()

        guard let rootViewController = window.rootViewController else {
            return nil
        }

        var topController = rootViewController
        while let newTopController = topController.presentedViewController {
            topController = newTopController
        }

        return topController
  }

    /// フルスクリーンのモーダルを閉じる
    public func closeModalView() {
        UIApplication.shared.getTopViewController()?.dismiss(animated: true, completion: nil)
    }
}

2. Viewの拡張

次はSwiftUIのViewでSheetViewやFullCreenCoverのようにフルスクリーンのモーダルが使用できるようにViewを拡張していきます。

View+Extension.swift
extension View {
    public func fullScreenView<Content>(
        isPresented: Binding<Bool>,
        @ViewBuilder content: @escaping () -> Content
    ) -> some View where Content: View {
        if isPresented.wrappedValue {
            let window = UIApplication.shared.windows.last
            window?.isHidden = true

            let view = content()
            let viewController = UIHostingController(rootView: view)
            viewController.modalPresentationStyle = .fullScreen

            DispatchQueue.main.async {
                guard let tvc = UIApplication.shared.getTopViewController() else {
                    return
                }

                tvc.present(viewController, animated: true, completion: nil)
                isPresented.wrappedValue = false
            }
        }

        return self
    }
}

実際に使ってみよう!

実際にViewで使用してみましょう!今回適当なサンプルViewを用意してみました!
こちら参考にみなさん是非使ってみてください:thumbsup:

ContentView.swift
struct ContentView: View {
    @State private var showModal: Bool = false

    var body: some View {
        VStack {
            Spacer()
            Button(action: {
                showModal.toggle()
            }, label: {
                Text("フルスクリーンのモーダルを表示する")
            })
            Spacer()
        }
        .fullScreenView(isPresented: $showModal) {
            ModalView()
        }
    }
}

struct ModalView: View {
    var body: some View {
        ZStack {
            Color.green.edgesIgnoringSafeArea(.all)
            Button(action: {
                UIApplication.shared.closeModalView()
            }, label: {
                Text("閉じる")
            })
        }
    }
}

ezgif-1-6f84b46cee07.gif

最後に

iOS13でSwiftUIとCombineを使用する際は、特にiOS13.0からiOS13.2までは気をつけましょう。バグがかなり多いです...
しかも、たちが悪いことにシュミレーションでは再現せず実機のみで再現するバグもいくつかあります。

実機がない場合は、SwiftUIをiOS13で使用することをおすすめしません:frowning2:
それでもiOS13を含めたい場合は最低でもiOS13.3以上にしましょう。

Let's Enjoy SwiftUI!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む