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)
}
}
}
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.
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
You can use .isValidInterfaceOrientation which returns only Portrait and Landscape orientations.
UIDevice.current.orientation.isValidInterfaceOrientation
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
}
}
}
}
Related
I have this:
VStack {
List {
LazyVGrid(columns: gridItemLayout) {
ForEach(viewModel.objects, id: \.fileGroupUUID) { item in
AlbumItemsScreenCell(object: item, viewModel: viewModel, config: Self.config)
.onTapGesture {
switch viewModel.changeMode {
case .moving, .sharing, .moveAll:
viewModel.toggleItemToChange(item: item)
case .none:
object = item
viewModel.showCellDetails = true
}
}
.onLongPressGesture {
viewModel.restartDownload(fileGroupUUID: item.fileGroupUUID)
}
} // end ForEach
} // end LazyVGrid
}
.listStyle(PlainListStyle())
.refreshable {
viewModel.refresh()
}
.padding(5)
// Mostly this is to animate updates from the menu. E.g., the sorting order.
.animation(.easeInOut)
// Had a problem with return animation for a while: https://stackoverflow.com/questions/65101561
// The solution was to take the NavigationLink out of the scrollview/LazyVGrid above.
if let object = object {
// The `NavigationLink` works here because the `MenuNavBar` contains a `NavigationView`.
NavigationLink(
destination:
ObjectDetailsView(object: object, model: ObjectDetailsModel(object: object)),
isActive:
$viewModel.showCellDetails) {
EmptyView()
}
.frame(width: 0, height: 0)
.disabled(true)
} // end if
} // end VStack
AlbumItemsScreenCell:
struct AlbumItemsScreenCell: View {
#StateObject var object:ServerObjectModel
#StateObject var viewModel:AlbumItemsViewModel
let config: IconConfig
#Environment(\.colorScheme) var colorScheme
var body: some View {
AnyIcon(model: AnyIconModel(object: object), config: config,
emptyUpperRightView: viewModel.changeMode == .none,
upperRightView: {
UpperRightChangeIcon(object: object, viewModel: viewModel)
})
}
}
When a user taps one of the cells, this causes navigation to a details screen. Sometimes when the user returns from that navigation, the cell in the upper left disappears:
https://www.dropbox.com/s/mi6j2ie7h8dcdm0/disappearingCell.mp4?dl=0
My current hypothesis about the issue is that when user actions in that details screen take actions which change viewModel.objects, this causes the disappearing cell problem. I'll be testing this hypothesis shortly.
----- Update, 11/1/21 ------
Well, that hypothesis was wrong. I now understand the structure of the problem more clearly. Still don't have a fix though.
Tapping on one of the AlbumItemsScreenCells navigates to a details screen (I've added to the code above to show that). In the details screen user actions can cause a comment count to get reset, which sends a Notification.
A model in the AlbumItemsScreenCell listens for these notification (for the specific cell) and resets a badge on the cell.
Here is that model:
class AnyIconModel: ObservableObject, CommentCountsObserverDelegate, MediaItemBadgeObserverDelegate, NewItemBadgeObserverDelegate {
#Published var mediaItemBadge: MediaItemBadge?
#Published var unreadCountBadgeText: String?
#Published var newItem: Bool = false
var mediaItemCommentCount:CommentCountsObserver!
let object: ServerObjectModel
var mediaItemBadgeObserver: MediaItemBadgeObserver!
var newItemObserver: NewItemBadgeObserver!
init(object: ServerObjectModel) {
self.object = object
// This is causing https://stackoverflow.com/questions/69783232/cell-in-list-with-lazyvgrid-disappears-sometimes
mediaItemCommentCount = CommentCountsObserver(object: object, delegate: self)
mediaItemBadgeObserver = MediaItemBadgeObserver(object: object, delegate: self)
newItemObserver = NewItemBadgeObserver(object: object, delegate: self)
}
}
The unreadCountBadgeText gets changed (on the main thread) by the observer when the Notification is received.
So, in summary, the badge on the cell gets changed while the screen with the cells is not displayed-- the details screen is displayed.
I had been using the following conditional modifier:
extension View {
public func enabled(_ enabled: Bool) -> some View {
return self.disabled(!enabled)
}
// https://forums.swift.org/t/conditionally-apply-modifier-in-swiftui/32815/16
#ViewBuilder func `if`<T>(_ condition: Bool, transform: (Self) -> T) -> some View where T : View {
if condition {
transform(self)
} else {
self
}
}
}
to display the badge on the AlbumItemsScreenCell.
The original badge looked like this:
extension View {
func upperLeftBadge(_ badgeText: String) -> some View {
return self.modifier(UpperLeftBadge(badgeText))
}
}
struct UpperLeftBadge: ViewModifier {
let badgeText: String
init(_ badgeText: String) {
self.badgeText = badgeText
}
func body(content: Content) -> some View {
content
.overlay(
ZStack {
Badge(badgeText)
}
.padding([.top, .leading], 5),
alignment: .topLeading
)
}
}
i.e., the usage looked like this in the cell:
.if(condition) {
$0.upperLeftBadge(badgeText)
}
when I changed the modifier to use it with out this .if modifier, and used it directly, the issue went away:
extension View {
func upperLeftBadge(_ badgeText: String?) -> some View {
return self.modifier(UpperLeftBadge(badgeText: badgeText))
}
}
struct UpperLeftBadge: ViewModifier {
let badgeText: String?
func body(content: Content) -> some View {
content
.overlay(
ZStack {
if let badgeText = badgeText {
Badge(badgeText)
}
}
.padding([.top, .leading], 5),
alignment: .topLeading
)
}
}
It may sounds weird, but in my tvOS app i disable the animations
and cells(Views) stops to disappear
So try to remove .animation(.easeInOut)
I'm using Parchment to add menu items at the top. The hierarchy of the main view is the following:
NavigationView
-> TabView
--> Parchment PagingView
---> NavigationLink(ChildView)
All works well going to the child view and then back again repeatedly. The issue happens when I go to ChildView, then go to the background/Home Screen then re-open. If I click back and then go to the child again the back button and the whole navigation bar disappears.
Here's code to replicate:
import SwiftUI
import Parchment
#main
struct ParchmentBugApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
TabView {
PagingView(items: [
PagingIndexItem(index: 0, title: "View 0"),
]) { item in
VStack {
NavigationLink(destination: ChildView()) {
Text("Go to child view")
}
}
.navigationBarHidden(true)
}
}
}
}
}
struct ChildView: View {
var body: some View {
VStack {
Text("Child View")
}
.navigationBarHidden(false)
.navigationBarTitle("Child View")
}
}
To replicate:
Launch and go to the child view
Click the home button to send the app to the background
Open the app again
Click on back
Navigate to the child view. The nav bar/back button are not there anymore.
What I noticed:
Removing the TabView makes the problem go away.
Removing PagingView also makes the problem go.
I tried to use a custom PagingController and played with various settings without success. Here's the custom PagingView if someone would like to tinker with the settings as well:
struct CustomPagingView<Item: PagingItem, Page: View>: View {
private let items: [Item]
private let options: PagingOptions
private let content: (Item) -> Page
/// Initialize a new `PageView`.
///
/// - Parameters:
/// - options: The configuration parameters we want to customize.
/// - items: The array of `PagingItem`s to display in the menu.
/// - content: A callback that returns the `View` for each item.
public init(options: PagingOptions = PagingOptions(),
items: [Item],
content: #escaping (Item) -> Page) {
self.options = options
self.items = items
self.content = content
}
public var body: some View {
PagingController(items: items, options: options,
content: content)
}
struct PagingController: UIViewControllerRepresentable {
let items: [Item]
let options: PagingOptions
let content: (Item) -> Page
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: UIViewControllerRepresentableContext<PagingController>) -> PagingViewController {
let pagingViewController = PagingViewController(options: options)
return pagingViewController
}
func updateUIViewController(_ pagingViewController: PagingViewController, context: UIViewControllerRepresentableContext<PagingController>) {
context.coordinator.parent = self
if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
} else {
pagingViewController.reloadData()
}
}
}
class Coordinator: PagingViewControllerDataSource {
var parent: PagingController
init(_ pagingController: PagingController) {
self.parent = pagingController
}
func numberOfViewControllers(in pagingViewController: PagingViewController) -> Int {
return parent.items.count
}
func pagingViewController(_: PagingViewController, viewControllerAt index: Int) -> UIViewController {
let view = parent.content(parent.items[index])
return UIHostingController(rootView: view)
}
func pagingViewController(_: PagingViewController, pagingItemAt index: Int) -> PagingItem {
return parent.items[index]
}
}
}
Tested on iOS Simulator 14.4 & 14.5, and device 14.5 beta 2.
Any tips or ideas are very much appreciated.
Okay I found the issue while debugging something else that was related to Parchment as well.
The issue is updateUIViewController() gets called each time the encompassing SwiftUI state changes (and when coming back to the foreground), and the PageController wrapper provided by the library will call reloadData() since the data source data has already been set. So to resolve this just remove/comment out the reloadData() call since the PageController will be re-built if the relevant state changes. The same issue was the cause for the bug I was debugging.
func updateUIViewController(_ pagingViewController: PagingViewController, context: UIViewControllerRepresentableContext<PagingController>) {
context.coordinator.parent = self
if pagingViewController.dataSource == nil {
pagingViewController.dataSource = context.coordinator
}
//else {
// pagingViewController.reloadData()
//}
}
Previously in WatchKit we could tell a certain InterfaceController to present itself using .becomeCurrentPage, how can we do it in Swift UI?
In WatchKit for example I would:
// handle notification
#objc func respondToWaterlock(_ notification: NSNotification) {
self.becomeCurrentPage()
}
there is no equivalent for becomeCurrentPage method in SwiftUI. You can update your State or ViewModel to achieve a similar result.
for example:
enum Pages {
case home
case settings
}
struct MyView: View {
#State var selectedPage: Pages = .home
var body: some View {
Group {
if self.selectedPage == .home {
Text("Home")
} else if self.selectedPage == .settings {
Text("Settings")
}
}
}
}
You should just update the selectedPage state variable to change the page.
I'm currently developing an app that has a single ViewController with a WKWebView object that acts as a client for a web app. It uses JavaScript communication and injection to allow for objects in native Swift to interact with the WebView.
My Objective
I have a function, urlDidChange(_ url: String), that fires whenever the WKWebView's raw URL value changes. I'm trying to dynamically set the orientation restrictions, depending on the value of said new url, and force-rotate the device to adapt those restrictions once a condition is met.
I'm not sure if this extra information is important, but thought that I'd include it anyway: The UIViewController is also embedded in a UINavigationController. The native NavBar really helps the client feel more like a native app. I haven't setup any custom classes for it and have simply been using let navBar = navigationController?.navigationBar.
Desired Example Usage
func urlDidChange(_ url: String) {
if url.contains("/dashboard") {
UIInterfaceOrientationMask = .portrait
} else if url.contains("/builder") {
UIInterfaceOrientationMask = [.portrait, .landscapeRight]
} else {
UIInterfaceOrientationMask = .all
}
// Set new orientation properties
// Force device rotation based on newly set properties
UIViewController.attemptRotationToDeviceOrientation()
}
Current Code
This is my current setup. I have attempted the following, with no luck:
enum Page {
var orientation: UIInterfaceOrientationMask {
switch self {
case .login: return getDeviceOrientation()
case .dashboard: return getDeviceOrientation()
case .newProject: return getDeviceOrientation()
case .builder: return getDeviceOrientation()
case .other: return getDeviceOrientation()
}
}
func getDeviceOrientation() -> UIInterfaceOrientationMask {
let phone = Device.isPhone()
switch self {
case .login:
if phone { return .portrait }
else { return .all }
case .dashboard:
if phone { return .portrait }
else { return .all }
case .newProject:
if phone { return .portrait }
else { return .all }
case .builder:
if phone { return [.portrait, .landscapeRight] }
else { return .all }
case .other:
if phone { return .portrait }
else { return .all }
}
}
ViewController
Finally, to apply the new orientation properties, I use this:
func urlDidChange(_ url: String) {
let page = Page.get(forURL: url) // Returns Page case for current URL
UIDevice.current.setValue(page.orientation.rawValue, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
Bonus question
Is there a more efficient way of using enumerations to combine the Device properties with my Page enum (ie. case .builder with different return values for when Device.isPhone or when Device.isPad)?
As you can see in the code above, I'm simply using if statements to determine which output to provide right now:
case .builder:
if phone { return [.portrait, .landscapeRight] }
else { return .all }
If your ViewController is inside a UINavigationController, that might be the reason. Can you try using a custom UINavigationController, that retrieves the orientation from your ViewController? Something like this:
struct OrientationConfig {
let phone: UIInterfaceOrientationMask
let pad: UIInterfaceOrientationMask
static let defaultConfig = OrientationConfig(phone: .portrait, pad: .all)
}
enum Page {
case login
case dashboard
case newProject
case builder
case other
static let orientationConfigs: [Page:OrientationConfig] = [
.login: OrientationConfig.defaultConfig,
.dashboard: OrientationConfig.defaultConfig,
.newProject: OrientationConfig.defaultConfig,
.builder: OrientationConfig(phone: [.portrait, .landscapeRight],
pad: .all),
.other: OrientationConfig.defaultConfig
]
}
class MyViewController: UIViewController {
var page: Page = .login
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if let config = Page.orientationConfigs[page] {
return Device.isPhone ? config.phone : config.pad
}
return super.supportedInterfaceOrientations
}
}
class MyNavigationController: UINavigationController {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if let viewController = topViewController as? MyViewController {
return viewController.supportedInterfaceOrientations
}
return super.supportedInterfaceOrientations
}
}
I want to respond to key presses, such as the esc key on macOS/OSX, and when using an external keyboard on iPad. How can I do this?
I have thought of using #available/#available with SwiftUI's onExitCommand, which looked very promising, but unfortunately that is only for macOS/OSX. How can I respond to key presses in SwiftUI for more than just macOS/OSX?
Update: SwiftUI 2 now has .keyboardShortcut(_:modifiers:).
OLD ANSWER:
With thanks to #Asperi to pointing me in the right direction, I have now managed to get this working.
The solution was to use UIKeyCommand. Here is what I did, but you can adapt it differently depending on your situation.
I have an #EnvironmentObject called AppState, which helps me set the delegate, so they keyboard input can be different depending on the view currently showing.
protocol KeyInput {
func onKeyPress(_ key: String)
}
class KeyInputController<Content: View>: UIHostingController<Content> {
private let state: AppState
init(rootView: Content, state: AppState) {
self.state = state
super.init(rootView: rootView)
}
#objc required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func becomeFirstResponder() -> Bool {
true
}
override var keyCommands: [UIKeyCommand]? {
switch state.current {
case .usingApp:
return [
UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(keyPressed(_:)))
]
default:
return nil
}
}
#objc private func keyPressed(_ sender: UIKeyCommand) {
guard let key = sender.input else { return }
state.delegate?.onKeyPress(key)
}
}
AppState (#EnvironmentObject):
class AppState: ObservableObject {
var delegate: KeyInput?
/* ... */
}
And the scene delegate looks something like:
let stateObject = AppState()
let contentView = ContentView()
.environmentObject(stateObject)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = KeyInputController(rootView: contentView, state: stateObject)
/* ... */
}
This makes it really easy to now add functionality depending on the keys pressed.
Conform to KeyInput, e.g.:
struct ContentView: View, KeyInput {
/* ... */
var body: some View {
Text("Hello world!")
.onAppear {
self.state.delegate = self
}
}
func onKeyPress(_ key: String) {
print(key)
guard key == UIKeyCommand.inputEscape else { return }
// esc key was pressed
/* ... */
}
}
On macOS and tvOS there is an onExitCommand(perform:) modifier for views.
From Apple's documentation:
Sets up an action that triggers in response to receiving the exit command while the view has focus.
The user generates an exit command by pressing the Menu button on tvOS, or the escape key on macOS.
For example:
struct ContentView: View {
var body: some View {
VStack {
TextField("Top", text: .constant(""))
.onExitCommand(perform: {
print("Exit from top text field")
})
TextField("Bottom", text: .constant(""))
.onExitCommand(perform: {
print("Exit from bottom text field")
})
}
.padding()
}
}
An easier solution is the .onExitCommand modifier.
As #Chuck H commented, this does not work if the text field is inside a ScrollView or like in my case, a Section.
But I discovered that just by embedding the TextField and its .onExitCommand inside a HStack or VStack, the .onExitCommand just works.
HStack { // This HStack is for .onExitCommand to work
TextField("Nombre del proyecto", text: $texto)
.onExitCommand(perform: {
print("Cancelando ediciĆ³n....")
})
.textFieldStyle(PlainTextFieldStyle())
.lineLimit(1)
}
.onAppear() {
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { (aEvent) -> NSEvent? in
if aEvent.keyCode == 53 { // if esc pressed
appDelegate.hideMainWnd()
return nil // do not do "beep" sound
}
return aEvent
}
}