[SwiftUI]画面を強制的に回転させる

今回の問題

以下のような処理を実装する必要があったためメモ

  • 特定の画面では横向きに固定したい
  • ボタンを押下すると画面の向きを変えたい

目次

まずは結論

以下のように実装することで実装可能。
SwiftではsupportedInterfaceOrientationsをオーバーライドするとか記載があるが、
SwiftUIでは明確な対処法が見つからなかった。

ファイル構造

コード一覧

全体の共通部分

import Foundation
import UIKit

class AppDelegate: NSObject, UIApplicationDelegate {
    
    static var orientationLock = UIInterfaceOrientationMask.all
    
    func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
        return AppDelegate.orientationLock
    }
}
import SwiftUI

@main
struct TestApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear {
                    AppDelegate.orientationLock = .all
                }
        }
    }
}

画面回転の処理部分

import Foundation
import UIKit

class ContentViewModel: NSObject, ObservableObject {
    @Published var screenOrientation: String
    
    override init() {
        screenOrientation = ""
    }
    
    // 時計回りに画面を回転させる
    func rotateClockWiseScreen() {
        switch AppDelegate.orientationLock {
        case .portrait:
            rotate(screenOrientation: .landscapeLeft)
            print("時計回り portrait → landscapeLeft")
        case .landscapeRight:
            rotate(screenOrientation: .portrait)
            print("時計回り landscapeRight → portrait")
        case .landscapeLeft:
            print("時計回りに回れないよ")
        case .all:
            // 画面の向きが固定されていない時の処理
            switch UIDevice.current.orientation {
            case .portrait:
                rotate(screenOrientation: .landscapeLeft)
                print("解除中 時計回り portrait → landscapeLeft")
            case .landscapeLeft:
                print("解除中 時計回りに回れないよ")
            case .landscapeRight:
                rotate(screenOrientation: .portrait)
                print("解除中 時計回り landscapeRight → portrait")
            default:
                print("UIDevice.current.orientation Unknown")
            }
        default:
            print("AppDelegate.orientationLock Unknown")
        }
    }
    
    // 反時計回りに画面を回転させる
    func rotateCounterClockWiseScreen() {
        switch AppDelegate.orientationLock {
        case .portrait:
            rotate(screenOrientation: .landscapeRight)
            print("反時計回り portrait → landscapeRight")
        case .landscapeLeft:
            rotate(screenOrientation: .portrait)
            print("反時計回り landscapeLeft → portrait")
        case .landscapeRight:
            print("反時計回りに回れないよ")
        case .all:
            // 画面の向きが固定されていない時の処理
            switch UIDevice.current.orientation {
            case .portrait:
                rotate(screenOrientation: .landscapeRight)
                print("解除中 反時計回り portrait → landscapeRight")
            case .landscapeLeft:
                rotate(screenOrientation: .portrait)
                print("解除中 反時計回り landscapeLeft → portrait")
            case .landscapeRight:
                print("解除中 反時計回りに回れないよ")
            default:
                print("UIDevice.current.orientation Unknown")
            }
        default:
            print("AppDelegate.orientationLock Unknown")
        }
    }
    
    func unRockScreenOrientation() {
        // 一旦現在の画面の向きに直す
        switch UIDevice.current.orientation {
        case .portrait:
            self.rotate(screenOrientation: .portrait)
            print("解除 portrait")
        case .landscapeLeft:
            // UIDeviceOrientationとUIInterfaceOrientationMaskとでは向きが逆?
            self.rotate(screenOrientation: .landscapeRight)
            print("解除 landscapeLeft")
        case .landscapeRight:
            // UIDeviceOrientationとUIInterfaceOrientationMaskとでは向きが逆?
            self.rotate(screenOrientation: .landscapeLeft)
            print("解除 landscapeRight")
        default:
            self.rotate(screenOrientation: .portrait)
            print("解除 不明 → portrait")
        }
        
        // 画面向きを固定している状態を解除する
        self.rotate(screenOrientation: .all)
        
        self.screenOrientation = ""
    }
    
    // 画面を回転させる処理
    private func rotate(screenOrientation :UIInterfaceOrientationMask) {
        // 現在の画面の状態を表示するための文章を更新
        self.getNowScreenOrientationString(screenOrientation: screenOrientation)
        
        AppDelegate.orientationLock = screenOrientation
        if #available(iOS 16.0, *) {
            ios16Rotate(screenOrientation: screenOrientation)
        } else {
            UIDevice.current.setValue(screenOrientation.rawValue, forKey: "orientation")
        }
    }
    
    // iOS16以降の場合の画面の回転処理を実行
    private func ios16Rotate(screenOrientation :UIInterfaceOrientationMask) {
        // 画面のUIWindowを取得
        guard let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first?.windows.filter({ $0.isKeyWindow }).first else {
            return
        }
        
        // SupportedInterfaceOrientationsを更新する
        window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
        
        if screenOrientation == .all {
            return
        }
        
        guard let windowScene = window.windowScene else {
            return
        }
        // 画面の向きの状態を更新して、向きを固定する
        windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: screenOrientation)) { error in
            print(error)
        }
    }
    
    // 現在の画面の状態を表示するための文章を更新
    private func getNowScreenOrientationString(screenOrientation :UIInterfaceOrientationMask) {
        switch screenOrientation {
        case .portrait:
            self.screenOrientation = "縦向き"
        case .landscapeLeft:
            self.screenOrientation = "横向き(カメラの位置が右にある)"
        case .landscapeRight:
            self.screenOrientation = "横向き(カメラの位置が左にある)"
        default:
            self.screenOrientation = ""
        }
    }
}

画面表示部分

import SwiftUI

struct ContentView: View {
    @ObservedObject var viewModel: ContentViewModel
    
    init() {
        self.viewModel = ContentViewModel()
    }
    
    var body: some View {
        VStack {
            Text(self.viewModel.screenOrientation)
                .font(.system(size: 20))
            HStack {
                Spacer()
                Button {
                    DispatchQueue.main.asyncAfter(deadline: .now()) {
                        self.viewModel.rotateClockWiseScreen()
                    }
                } label: {
                    Image(systemName: "arrow.clockwise")
                        .font(.system(size: 20))
                }
                Spacer()
                Button {
                    DispatchQueue.main.asyncAfter(deadline: .now()) {
                        self.viewModel.unRockScreenOrientation()
                    }
                } label: {
                    Image(systemName: "lock.open")
                        .font(.system(size: 20))
                }
                Spacer()
                Button {
                    DispatchQueue.main.asyncAfter(deadline: .now()) {
                        self.viewModel.rotateCounterClockWiseScreen()
                    }
                } label: {
                    Image(systemName: "arrow.counterclockwise")
                        .font(.system(size: 20))
                }
                Spacer()
            }
        }
    }
}

実際の動作

解説

まず、画面を固定するコードを用意する。
このorientationLockに指定した画面の向きを固定する。

static var orientationLock = UIInterfaceOrientationMask.all
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
    return AppDelegate.orientationLock
}

そのため、画面の向きを変えるときは以下の変数を更新してあげればいい。
例えば、画面を横向き(カメラの位置が左)の場合は以下のようにすればいい。

AppDelegate.orientationLock = .landscapeLeft

しかし、これだけでは二度目以降に画面の向きを変えるときに以下のようなエラーが出て動かない。

Error Domain=UISceneErrorDomain Code=101 “None of the requested orientations are supported by the view controller. Requested: portrait; Supported: landscapeLeft” UserInfo={NSLocalizedDescription=None of the requested orientations are supported by the view controller. Requested: portrait; Supported: landscapeLeft}

supportedInterfaceOrientationsがおかしいとか。
調べてみるとZenn様のSwiftのorientation操作からswiftではsupportedInterfaceOrientationsをオーバーライドしてあげればいいとのこと。
しかし、SwiftUIでは、以下の処理を行うことで問題が解決。

guard let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first?.windows.filter({ $0.isKeyWindow }).first else {
    return
}        
// SupportedInterfaceOrientationsを更新する
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()

要はsetNeedsUpdateOfSupportedInterfaceOrientationsメソッドを呼び出せばいいとのこと。
そして、画面の向きを実際に変える処理は以下の処理を行う。

windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: screenOrientation)) { error in
    print(error)
}

ただし、こちらはiOS16以降の処理で、iOS15まででは以下で行う。

UIDevice.current.setValue(screenOrientation.rawValue, forKey: "orientation")

意外とこういうのは、できそうでできないんですよね…

仕事で作るアプリは画面の向きがportrait固定のものが多いけど、
「向きを変えたい。しかも一部の画面だけ」
という要望は今後もありそうだし、ちゃんと残しておこう。