iOS 8 起,Apple 收緊 UIDevice.setValue(_:forKey: "orientation") 等私有 API,且 supportedInterfaceOrientations、shouldAutorotate 等機制在導航控制器、分頁控制器嵌套場景下行為不一致,導致「單頁強制橫向」變得棘手。
核心思路:不改裝置方向,只旋轉畫面內容。優酷、愛奇藝等主流視訊 App 採用此架構。
protocol RotatableViewController: AnyObject {
var isLandscape: Bool { get set }
func rotateToLandscape(animated: Bool)
func rotateToPortrait(animated: Bool)
}
extension RotatableViewController where Self: UIViewController {
func rotateToLandscape(animated: Bool = true) {
guard !isLandscape else { return }
isLandscape = true
let transform = CGAffineTransform(rotationAngle: .pi / 2)
.concatenating(CGAffineTransform(translationX: 0, y: 0))
let animations = {
self.view.transform = transform
self.view.bounds = UIScreen.main.bounds // 關鍵:重設 bounds 適配新方向
}
if animated {
UIView.animate(withDuration: 0.3, animations: animations)
} else {
animations()
}
}
func rotateToPortrait(animated: Bool = true) {
guard isLandscape else { return }
isLandscape = false
let animations = {
self.view.transform = .identity
self.view.bounds = UIScreen.main.bounds
}
if animated {
UIView.animate(withDuration: 0.3, animations: animations)
} else {
animations()
}
}
}
class VideoPlayerViewController: UIViewController, RotatableViewController {
var isLandscape = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
rotateToLandscape() // 進入即強制橫向
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
rotateToPortrait() // 離開恢復直向
}
// 支援裝置實際旋轉時的同步
override var shouldAutorotate: Bool { false } // 禁用系統自動旋轉
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .portrait }
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .portrait }
}
func application(_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
// 只有特定 VC 允許橫向
if let topVC = topViewController(),
topVC is RotatableViewController,
(topVC as! RotatableViewController).isLandscape {
return .landscapeRight
}
return .portrait
}
| 問題 | 解決方案 |
|---|---|
| 狀態列不旋轉 | 重寫 prefersStatusBarHidden、preferredStatusBarStyle,動畫同步更新 |
| 子視圖佈局錯亂 | 使用 Auto Layout + view.bounds 重設,避免硬編碼 frame |
| 轉場動畫衝突 | 在 viewWillDisappear 完成旋轉後再執行 pop/dismiss |
| iPad 多任務分割視圖 | 檢查 traitCollection.horizontalSizeClass,分割模式下禁用強制旋轉 |
| 安全區域適配 | 旋轉後呼叫 view.setNeedsLayout()、view.layoutIfNeeded() |
// 滑動邊緣觸發旋轉(類似控制中心)
let edgePan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleEdgePan(_:)))
edgePan.edges = .right
view.addGestureRecognizer(edgePan)
@objc func handleEdgePan(_ recognizer: UIScreenEdgePanGestureRecognizer) {
let progress = recognizer.translation(in: view).x / view.bounds.width
switch recognizer.state {
case .changed: updateRotationProgress(progress)
case .ended: progress > 0.5 ? commitRotation() : cancelRotation()
default: break
}
}
| 方案 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| 仿射變換動畫 | 相容性佳、動畫流暢、不影響其他頁面 | 需手動處理狀態列、安全區域、轉場協調 | 視訊播放、遊戲、特定功能頁 |
| 私有 API (setValue) | 程式碼極少 | App Store 審核風險、iOS 版本易失效 | 不推薦 |
| 獨立 UIWindow + rootVC | 完全隔離、系統級旋轉 | 記憶體開銷大、狀態同步複雜 | 全螢幕沉浸式體驗 |
| UINavigationController 子類別 | 集中管理推疊頁面方向 | 侵入性強、耦合度高 | 整個導航棧需統一方向 |
iOS 8+ 單頁強制橫向的工業級標準做法是 仿射變換動畫 + 狀態機同步。雖需處理狀態列、安全區域、轉場協調等細節,但提供最穩定的跨版本相容性與使用者體驗。建議封裝為可復用的 RotatableViewController 協議與基底類別,專案內統一管理。