SwiftUI - Determining Current Device and Orientation
Asked Answered
L

3

16

I am trying to detect when the device is on iPad and in Portrait.

Currently I use the UIDevice API in UIKit and use an environment object to watch changes. I use the solution found here - Determining Current Device and Orientation.

However the orientationInfo.orientation is initially always equal to .portrait until rotated into portrait and then back to landscape.

So when doing the following to display the FABView

struct HomeView: View {

@EnvironmentObject var orientationInfo: OrientationInfo
let isPhone = UIDevice.current.userInterfaceIdiom == .phone

    var body: some View {
      ZStack(alignment: .bottom) {
          #if os(iOS)
          if isPhone == false && orientationInfo.orientation == .portrait {
            FABView()
          }
          #endif
      }
    }
}

The view is loaded when the iPad is initially in landscape, but when changing to portrait and back to landscape is then removed. Why is this happening and how can I make sure the view isn't loaded on first load ?

Full Code

struct HomeTab: View {
    
    var body: some View {
        NavigationView {
            HomeView()
                .environmentObject(OrientationInfo())
        }
    }
}

struct HomeView: View {

@EnvironmentObject var orientationInfo: OrientationInfo
let isPhone = UIDevice.current.userInterfaceIdiom == .phone

    var body: some View {
      ZStack(alignment: .bottom) {
          #if os(iOS)
          if isPhone == false && orientationInfo.orientation == .portrait {
            FABView()
          }
          #endif
      }
    }
}

final class OrientationInfo: ObservableObject {
    enum Orientation {
        case portrait
        case landscape
    }
    
    @Published var orientation: Orientation
    
    private var _observer: NSObjectProtocol?
    
    init() {
        // fairly arbitrary starting value for 'flat' orientations
        if UIDevice.current.orientation.isLandscape {
            self.orientation = .landscape
        }
        else {
            self.orientation = .portrait
        }
        
        // unowned self because we unregister before self becomes invalid
        _observer = NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { [unowned self] note in
            guard let device = note.object as? UIDevice else {
                return
            }
            if device.orientation.isPortrait {
                self.orientation = .portrait
            }
            else if device.orientation.isLandscape {
                self.orientation = .landscape
            }
        }
    }
    
    deinit {
        if let observer = _observer {
            NotificationCenter.default.removeObserver(observer)
        }
    }
}
Lipski answered 5/1, 2021 at 5:1 Comment(0)
G
19

You can use UIDevice.orientationDidChangeNotification for detecting orientation changes but you shouldn't rely on it when the app starts.

UIDevice.current.orientation.isValidInterfaceOrientation will be false at the beginning and therefore both

  • UIDevice.current.orientation.isLandscape

and

  • UIDevice.current.orientation.isPortrait

will return false.

Instead you can use interfaceOrientation from the first window scene:

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
            }
    }
}

Also note that device orientation is not equal to interface orientation. When the device is upside down in portrait mode, device orientation is portrait but interface orientation can be landscape as well.

I think it's better to rely on the interface orientation in your case.

Goosestep answered 5/1, 2021 at 21:29 Comment(1)
Excellent solution. I'd only add that, if you want to know the interface orientation on launch, without waiting for a rotation to occur, it's possible to add the same code to .onAppearFriedlander
P
2

I would like to add another details that can help in some cases:

You can get orientation by rawValue:

UIDevice.current.orientation.rawValue
  • Portrait = 1
  • PortraitUpSideDown = 2
  • LandscapeLeft = 3 (Top of the Device go left)
  • LandscapeRight = 4 (Top of the Device go right)

What is often forgotten is that there is also a Flat orientation. This can be an issue when you test your app on the physical device.

  • Flat = 5 (Device is on the back)
  • Flat = 6 (Device is on the front/screen)

This is important when you use Notification for UIDevice.orientationDidChangeNotification.

Because you will get notification every time user change Flat orientation, but you expected only to get notified when it goes from Portrait to Landscape and vice versa.

Solutions to this problem

  1. You can use .isValidInterfaceOrientation which returns only Portrait and Landscape orientations.

UIDevice.current.orientation.isValidInterfaceOrientation

  1. You can change var only when rawValue is from 1...4

    struct ContentView: View {
        @State private var isPortrait = false
    
        var body: some View {
            Text("isPortrait: \(String(isPortrait))")
                .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
    
                    if UIDevice.current.orientation.rawValue <= 4 { // This will run code only on Portrait and Landscape changes
                        guard let scene = UIApplication.shared.windows.first?.windowScene else { return }
                        self.isPortrait = scene.interfaceOrientation.isPortrait
                    }
                }
        }
    } 
    
Phillip answered 22/10, 2022 at 17:32 Comment(0)
Z
1

I have created a more SwiftUI like solution for detecting orientation changes, which I tested on Xcode 15.4 with the iOS 17.5 simulator. This solution ensures that your SwiftUI views can dynamically respond to device orientation changes while also providing access to screen size, using EnvironmentKey.

Usage in a SwiftUI View

To use the orientation EnvironmentKey:

struct ContentView: View {

  @Environment(\.orientation) private var orientation

  var body: some View {
    ZStack {
      Color.clear
 
      Text(orientation.isLandscape ? "Landscape" : "Portrait")
    }
  }
}

Setup in the App Entry Point

The setup must be done within the App start:

@main
struct StackOverflowApp: App {

  var body: some Scene {
    WindowGroup {
      GeometryReader { proxy in
        ContentView()
          .environment(\.orientation, UIDevice.current.orientation)
          .environment(\.screenSize, proxy.size)
      }
    }
  }
}

Explanation

  • Environment Keys: The orientation and screenSize keys allow you to access these values throughout your SwiftUI views.
  • GeometryReader: Detects screen size changes, which also trigger orientation updates.

EnvironmentKey Definitions

Define the EnvironmentKey for orientation and screen size:

extension EnvironmentValues {

  var orientation: UIDeviceOrientation {
    get { self[OrientationKey.self] }
    set { self[OrientationKey.self] = newValue }
  }

  var screenSize: CGSize {
    get { self[ScreenSizeKey.self] }
    set { self[ScreenSizeKey.self] = newValue }
  }
}

private struct OrientationKey: EnvironmentKey {
  static let defaultValue = UIDevice.current.orientation
}

private struct ScreenSizeKey: EnvironmentKey {
  static let defaultValue: CGSize = .zero
}

Using the screenSize Environment Key

If you ever need to use the screenSize bonus key, you can do so within any SwiftUI view:

@Environment(\.screenSize) private var screenSize
Zeitler answered 28/6 at 9:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.