SwiftUI 在设备旋转时重新绘制视图组件



如何在 SwiftUI 中检测设备旋转并重新绘制视图组件?

我有一个@State变量初始化为UIScreen.main.bounds.width的值,当第一个出现时。但是,当设备方向更改时,此值不会更改。当用户更改设备方向时,我需要重绘所有组件。

下面是一个基于通知发布者的惯用 SwiftUI 实现:

struct ContentView: View {

@State var orientation = UIDevice.current.orientation
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some View {
Group {
if orientation.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}.onReceive(orientationChanged) { _ in
self.orientation = UIDevice.current.orientation
}
}
}

发布者的输出(上面未使用,因此_作为块参数)也包含其userInfo属性中的键"UIDeviceOrientationRotateAnimatedUserInfoKey",如果您需要知道是否应该对旋转进行动画处理。

@dfd提供了两个不错的选择,我添加了第三个,这是我使用的那个。

在我的情况下,我子类UIHostingController,在函数视图WillTransition中,我发布了一个自定义通知。

然后,在我的环境模型中,我侦听这样的通知,然后可以在任何视图中使用。

struct ContentView: View {
@EnvironmentObject var model: Model
var body: some View {
Group {
if model.landscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
}
}

在场景委托中.swift:

window.rootViewController = MyUIHostingController(rootView: ContentView().environmentObject(Model(isLandscape: windowScene.interfaceOrientation.isLandscape)))

我的 UIHostingController 子类:

extension Notification.Name {
static let my_onViewWillTransition = Notification.Name("MainUIHostingController_viewWillTransition")
}
class MyUIHostingController<Content> : UIHostingController<Content> where Content : View {
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
NotificationCenter.default.post(name: .my_onViewWillTransition, object: nil, userInfo: ["size": size])
super.viewWillTransition(to: size, with: coordinator)
}
}

我的模型:

class Model: ObservableObject {
@Published var landscape: Bool = false
init(isLandscape: Bool) {
self.landscape = isLandscape // Initial value
NotificationCenter.default.addObserver(self, selector: #selector(onViewWillTransition(notification:)), name: .my_onViewWillTransition, object: nil)
}
@objc func onViewWillTransition(notification: Notification) {
guard let size = notification.userInfo?["size"] as? CGSize else { return }
landscape = size.width > size.height
}
}

有一个比@kontiki提供的更简单的解决方案,不需要通知或与 UIKit 集成。

在场景委托中.swift:

func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
model.environment.toggle()
}

在模型中.swift:

final class Model: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var environment: Bool = false { willSet { objectWillChange.send() } }
}

净效果是,每次环境更改时,无论是旋转、大小变化等,都会重新绘制依赖于@EnvironmentObject model的视图。

SwiftUI 2

这是一个不使用SceneDelegate的解决方案(新的 SwiftUI 生命周期中缺少)。

它还使用当前窗口场景中的interfaceOrientation,而不是UIDevice.current.orientation(应用启动时未设置)。

这是一个演示:

struct ContentView: View {
@State private var isPortrait = false

var body: some View {
Text("isPortrait: (String(isPortrait))")
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
guard let scene = UIApplication.shared.windows.first?.windowScene else { return }
self.isPortrait = scene.interfaceOrientation.isPortrait
}
}
}

也可以使用扩展来访问当前窗口场景:

extension UIApplication {
var currentScene: UIWindowScene? {
connectedScenes
.first { $0.activationState == .foregroundActive } as? UIWindowScene
}
}

并像这样使用它:

guard let scene = UIApplication.shared.currentScene else { return }

如果有人也对初始设备方向感兴趣。我这样做如下:

设备.swift

import Combine
final class Device: ObservableObject {
@Published var isLandscape: Bool = false
}

场景代表.swift

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
// created instance
let device = Device() // changed here
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// ...
// added the instance as environment object here
let contentView = ContentView().environment(.managedObjectContext, context).environmentObject(device) 

if let windowScene = scene as? UIWindowScene {
// read the initial device orientation here
device.isLandscape = (windowScene.interfaceOrientation.isLandscape == true)
// ...            
}
}
// added this function to register when the device is rotated
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
device.isLandscape.toggle()
}
// ...
}

我认为

添加
@Environment(.verticalSizeClass) var sizeClass

以查看结构。

我有这样的例子:

struct MainView: View {
@EnvironmentObject var model: HamburgerMenuModel
@Environment(.verticalSizeClass) var sizeClass
var body: some View {
let tabBarHeight = UITabBarController().tabBar.frame.height
return ZStack {
HamburgerTabView()
HamburgerExtraView()
.padding(.bottom, tabBarHeight)
}
}
}

如您所见,我需要重新计算 tabBarHeight 以在额外视图上应用正确的底部填充,并且添加此属性似乎可以正确触发重新绘制。

只需一行代码!

我尝试了一些以前的答案,但遇到了一些问题。其中一个解决方案可以在 95% 的时间内工作,但会时不时地搞砸布局。其他解决方案似乎与 SwiftUI 的做事方式不一致。所以我想出了自己的解决方案。您可能会注意到它结合了之前几个建议的功能。

// Device.swift
import Combine
import UIKit
final public class Device: ObservableObject {
@Published public var isLandscape: Bool = false
public init() {}
}
//  SceneDelegate.swift
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var device = Device()
func scene(_ scene: UIScene, 
willConnectTo session: UISceneSession, 
options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
.environmentObject(device)
if let windowScene = scene as? UIWindowScene {
// standard template generated code
// Yada Yada Yada
let size = windowScene.screen.bounds.size
device.isLandscape = size.width > size.height
}
}
// more standard template generated code
// Yada Yada Yada
func windowScene(_ windowScene: UIWindowScene, 
didUpdate previousCoordinateSpace: UICoordinateSpace, 
interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, 
traitCollection previousTraitCollection: UITraitCollection) {
let size = windowScene.screen.bounds.size
device.isLandscape = size.width > size.height
}
// the rest of the file
// ContentView.swift
import SwiftUI
struct ContentView: View {
@EnvironmentObject var device : Device
var body: some View {
VStack {
if self.device.isLandscape {
// Do something
} else {
// Do something else
}
}
}
} 

受到@caram解决方案的启发,我从windowScene中获取了isLandscape属性

SceneDelegate.swift中,从window.windowScene.interfaceOrientation获取当前方向

...
var model = Model()
...
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {    
model.isLandScape = windowScene.interfaceOrientation.isLandscape
}

这样,如果用户从横向模式启动应用,我们将从一开始就获得true

这是Model

class Model: ObservableObject {
@Published var isLandScape: Bool = false
}

我们可以以与建议完全相同的方式使用它@kontiki

struct ContentView: View {
@EnvironmentObject var model: Model
var body: some View {
Group {
if model.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
}
}

这是一个抽象,允许您以可选的基于方向的行为包装视图树的任何部分,作为奖励,它不依赖于 UIDevice 方向,而是基于空间的几何形状,这允许它在快速预览中工作,并为专门基于视图容器的不同布局提供逻辑:

struct OrientationView<L: View, P: View> : View {
let landscape : L
let portrait : P
var body: some View {
GeometryReader { geometry in
Group {
if geometry.size.width > geometry.size.height { self.landscape }
else { self.portrait }
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
init(landscape: L, portrait: P) {
self.landscape = landscape
self.portrait = portrait
}
}
struct OrientationView_Previews: PreviewProvider {
static var previews: some View {
OrientationView(landscape: Text("Landscape"), portrait: Text("Portrait"))
.frame(width: 700, height: 600)
.background(Color.gray)
}
}

用法:OrientationView(landscape: Text("Landscape"), portrait: Text("Portrait"))

无需通知、委派方法、事件、SceneDelegate.swift更改、window.windowScene.interfaceOrientation等都很容易。 尝试在模拟器和旋转设备中运行它。

struct ContentView: View {
let cards = ["a", "b", "c", "d", "e"]
@Environment(.horizontalSizeClass) var horizontalSizeClass
var body: some View {
let arrOfTexts = {
ForEach(cards.indices) { (i) in
Text(self.cards[i])
}
}()
if (horizontalSizeClass == .compact) {
return VStack {
arrOfTexts
}.erase()
} else {
return VStack {
HStack {
arrOfTexts
}
}.erase()
}
}
}
extension  View {
func erase() -> AnyView {
return AnyView(self)
}
}

在 iOS 14 中执行此操作的最佳方法:

// GlobalStates.swift
import Foundation
import SwiftUI
class GlobalStates: ObservableObject {
@Published var isLandScape: Bool = false
}

// YourAppNameApp.swift
import SwiftUI
@main
struct YourAppNameApp: App {
// GlobalStates() is an ObservableObject class
var globalStates = GlobalStates()
// Device Orientation
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(globalStates)
.onReceive(orientationChanged) { _ in
// Set the state for current device rotation
if UIDevice.current.orientation.isFlat {
// ignore orientation change
} else {
globalStates.isLandscape = UIDevice.current.orientation.isLandscape
}
}
}
}
// Now globalStates.isLandscape can be used in any view
// ContentView.swift
import SwiftUI
struct ContentView: View {
@EnvironmentObject var globalStates: GlobalStates
var body: some View {
VStack {
if globalStates.isLandscape {
// Do something
} else {
// Do something else
}
}
}
} 

我想知道 SwiftUI 中是否有简单的解决方案可以与任何封闭的视图配合使用,以便它可以确定不同的横向/纵向布局。 正如@dfd简要提到的,GeometryReader可用于触发更新。

请注意,这适用于使用标准尺寸等级/特征无法提供足够信息来实现设计的特殊场合。例如,纵向和横向需要不同的布局,但两个方向都会导致从环境中返回标准大小类。这种情况发生在最大的设备上,例如最大尺寸的手机和iPad。

这是"幼稚"版本,这不起作用。

struct RotatingWrapper: View {

var body: some View {
GeometryReader { geometry in
if geometry.size.width > geometry.size.height {
LandscapeView()
}
else {
PortraitView()
}
}
}
}

以下版本是可旋转类的变体,它是@reuschj函数构建器的一个很好的例子,但只是针对我的应用程序需求进行了简化 https://github.com/reuschj/RotatableStack/blob/master/Sources/RotatableStack/RotatableStack.swift

这确实有效

struct RotatingWrapper: View {

func getIsLandscape(geometry:GeometryProxy) -> Bool {
return geometry.size.width > geometry.size.height
}

var body: some View {
GeometryReader { geometry in
if self.getIsLandscape(geometry:geometry) {
Text("Landscape")
}
else {
Text("Portrait").rotationEffect(Angle(degrees:90))
}
}
} 
}

这很有趣,因为我假设一些 SwiftUI 魔法导致了这个看似简单的语义更改来激活视图重新渲染。

您可以使用它的另一个奇怪的技巧是以这种方式"破解"重新渲染,丢弃使用 GeometryProxy 的结果并执行设备方向查找。然后,这可以使用所有方向,在此示例中,细节被忽略,结果用于触发简单的纵向和横向选择或其他任何需要的内容。

enum  Orientation {
case landscape 
case portrait 
}
struct RotatingWrapper: View {

func getOrientation(geometry:GeometryProxy) -> Orientation {
let _  = geometry.size.width > geometry.size.height
if   UIDevice.current.orientation == UIDeviceOrientation.landscapeLeft || UIDevice.current.orientation == UIDeviceOrientation.landscapeRight {
return .landscape
}
else {
return .portrait
}
}

var body: some View {
ZStack {
GeometryReader { geometry in
if  self.getOrientation(geometry: geometry) == .landscape {
LandscapeView()
}
else {
PortraitView()
}
}
}
}

}

此外,刷新顶级视图后,您可以直接使用 DeviceOrientation,例如在子视图中执行以下操作,因为一旦顶级视图"失效",将检查所有子视图

例如:在 LandscapeView() 中,我们可以根据其水平位置适当地格式化子视图。

struct LandscapeView: View {

var body: some View {
HStack   {
Group {
if  UIDevice.current.orientation == UIDeviceOrientation.landscapeLeft {
VerticallyCenteredContentView()
}
Image("rubric")
.resizable()
.frame(width:18, height:89)
//.border(Color.yellow)
.padding([UIDevice.current.orientation == UIDeviceOrientation.landscapeLeft ? .trailing : .leading], 16)
}
if  UIDevice.current.orientation == UIDeviceOrientation.landscapeRight {
VerticallyCenteredContentView()
}
}.border(Color.pink)
}
}

这似乎对我有用。 然后只需初始化并使用方向实例作为环境对象

class Orientation: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var isLandScape:Bool = false {
willSet {
objectWillChange.send() }
}
var cancellable: Cancellable?
init() {
cancellable = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.map() { _ in (UIDevice.current.orientation == .landscapeLeft || UIDevice.current.orientation == .landscapeRight)}
.removeDuplicates()
.assign(to: .isLandScape, on: self)
}
}

我得到了

"致命错误:未找到 SomeType 类型的可观察对象">

因为我忘了在 SceneDelegate.swift 中调用 contentView.environmentObject(orientationInfo)。这是我的工作版本:

// OrientationInfo.swift
final class OrientationInfo: ObservableObject {
@Published var isLandscape = false
}
// SceneDelegate.swift
var orientationInfo = OrientationInfo()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// ...
window.rootViewController = UIHostingController(rootView: contentView.environmentObject(orientationInfo))
// ...
}
func windowScene(_ windowScene: UIWindowScene, didUpdate previousCoordinateSpace: UICoordinateSpace, interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation, traitCollection previousTraitCollection: UITraitCollection) {
orientationInfo.isLandscape = windowScene.interfaceOrientation.isLandscape
}
// YourView.swift
@EnvironmentObject var orientationInfo: OrientationInfo
var body: some View {
Group {
if orientationInfo.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
}

尝试使用horizontalSizeClass&verticalSizeClass

import SwiftUI
struct DemoView: View {

@Environment(.horizontalSizeClass) var hSizeClass
@Environment(.verticalSizeClass) var vSizeClass

var body: some View {
VStack {
if hSizeClass == .compact && vSizeClass == .regular {
VStack {
Text("Vertical View")
}
} else {
HStack {
Text("Horizontal View")
}
}
}
}
}

在本教程中找到它。相关苹果的文档。

另一个检测方向变化的技巧,也是拆分视图。(灵感来自@Rocket花园)

import SwiftUI
import Foundation
struct TopView: View {
var body: some View {
GeometryReader{
geo in
VStack{
if keepSize(geo: geo) {
ChildView()
}
}.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
}.background(Color.red)
}

func keepSize(geo:GeometryProxy) -> Bool {
MyScreen.shared.width  = geo.size.width
MyScreen.shared.height = geo.size.height
return true
}
}
class MyScreen:ObservableObject {
static var shared:MyScreen = MyScreen()
@Published var width:CGFloat = 0
@Published var height:CGFloat = 0
}
struct ChildView: View {
// The presence of this line also allows direct access to up-to-date UIScreen.main.bounds.size.width & .height
@StateObject var myScreen:MyScreen = MyScreen.shared

var body: some View {
VStack{
if myScreen.width > myScreen.height {
Text("Paysage")
} else {
Text("Portrait")
}
}
}
}

我已经更新了 https://stackoverflow.com/a/62370919/7139611 以加载它作为初始视图,并使用环境对象将其全局工作。

import SwiftUI
class Orientation: ObservableObject {
@Published var isLandscape: Bool = UIDevice.current.orientation.isLandscape
}
struct ContentView: View {

@StateObject var orientation = Orientation()
@State var initialOrientationIsLandScape = false
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
var body: some View {
Group {
if orientation.isLandscape {
Text("LANDSCAPE")
} else {
Text("PORTRAIT")
}
}
.onReceive(orientationChanged, perform: { _ in
if initialOrientationIsLandScape {
initialOrientationIsLandScape = false
} else {
orientation.isLandscape = UIDevice.current.orientation.isLandscape
}
})
.onAppear {
orientation.isLandscape = UIDevice.current.orientation.isLandscape
initialOrientationIsLandScape = orientation.isLandscape
}
}
}

对于那些希望在设备旋转更改时操纵其他一些变量/状态的人,这里有一个解决方案:

struct ContentView: View {
@Environment(.verticalSizeClass) private var verticalSizeClass
var body: some View {
VStack {
...
}
.onChange(of: verticalSizeClass, perform: { newValue in
// Update your variables/state here
}
}
}

使用verticalSizeClass而不是horizontalSizeClass很重要,因为前者在iPhone方向更改时会发生变化,但对于某些iPhone型号,后者不会在设备旋转时发生变化。

这在iPad/macOS上也不起作用 - 您需要使用水平和垂直大小类的组合来检测这些类别的旋转。您可以在">设备尺寸类别"子标题下查看各种配置以及尺寸类将报告的值:https://developer.apple.com/design/human-interface-guidelines/foundations/layout

最新更新