UIViewRepresentable for UITextField produces broken UI Behavior in SwiftUI - swiftui

I have broken our production code down to the essentials.
We wrote a UIViewRepresentable for the UITextField. To make the textfeild the first responder we defined IsEditing as a bindable value. We pass this value to the coordinator and update it accordingly in didBeginEditing and didEndEditing of the UITextFieldDelegate. Basically everything works as expected, but as soon as you display a view above this view that also claims the FirstResponder and navigate back again the UI below seems to be broken. Everything looks right at first glance, but if you take a closer look in the view debugger, the horror becomes obvious. The controls seem to be positioned correctly on the visual plane, but the frames are slightly offset under the hood.
This is the Code for our UIViewRepresentable:
import SwiftUI
struct UITextFieldRepresentable: UIViewRepresentable {
#Binding var isEditing: Bool
init(isEditing: Binding<Bool>) {
self._isEditing = isEditing
}
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
if isEditing && !uiView.isFirstResponder {
uiView.becomeFirstResponder()
} else if !isEditing && uiView.isFirstResponder {
uiView.resignFirstResponder()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(isEditing: $isEditing)
}
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var isEditing: Bool
init(isEditing: Binding<Bool>) {
self._isEditing = isEditing
}
func textFieldDidBeginEditing(_ textField: UITextField) {
if !isEditing {
self.isEditing = true
}
}
func textFieldDidEndEditing(_ textField: UITextField) {
if isEditing {
self.isEditing = false
}
}
}
}
And this the code which produces the error.
struct ContentView: View {
var body: some View {
TestView()
}
}
class ViewModel: ObservableObject {
#Published var isEditing1: Bool = false
}
struct TestView: View {
#StateObject var viewModel = ViewModel()
#Environment(\.presentationMode) var presentationMode
#State var showSheet = false
var body: some View {
ScrollView {
VStack {
Button("dismiss") {
presentationMode.wrappedValue.dismiss()
}
UITextFieldRepresentable(isEditing: $viewModel.isEditing1)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.blue, style: StrokeStyle()))
Button("TestButton") {
}
.background(Color.red)
Button("TestButton") {
}
.background(Color.yellow)
Button("ShowSheet") {
showSheet = true
}
.background(Color.green)
}
}.sheet(isPresented: $showSheet, content: {
TestView()
})
}
}
Here are the steps to reproduce the behavior:
You need to activate the first textfield.
Press the show sheet dialog
Active the textfield in the newly displayed view
Dismiss the sheet by pressing the dismiss button
Try to click the show sheet button -> The position of the click now seems to have an offset
We have already tried many things to solve the problem,
but have not yet found a good solution or the actual cause.
Does anyone have an idea what is going wrong?
UPDATE:
The Issue also appears when using a simple SwiftUITextField.

Related

Expand and Collapse UIDatePicker as countDownTimer in SwiftUI

I want to recreate time duration picker similar to this Project or https://github.com/rajtharan-g/InlineDatePicker or https://www.youtube.com/watch?v=-E4J0yClmME in SwiftUI. So far I created a DurationPicker Element.
struct DurationPicker: UIViewRepresentable {
#Binding var duration: TimeInterval
func makeUIView(context: Context) -> UIDatePicker {
let datePicker = UIDatePicker()
datePicker.datePickerMode = .countDownTimer
datePicker.addTarget(context.coordinator, action: #selector(Coordinator.updateDuration), for: .valueChanged)
return datePicker
}
func updateUIView(_ datePicker: UIDatePicker, context: Context) {
datePicker.countDownDuration = duration
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
let parent: DurationPicker
init(_ parent: DurationPicker) {
self.parent = parent
}
#objc func updateDuration(datePicker: UIDatePicker) {
parent.duration = datePicker.countDownDuration
}
}
}
My Implementation of it:
import SwiftUI
struct TestView: View {
#State private var betaAccount = false
#State private var duration_1: TimeInterval = 0
#State private var isHidden_1 = false
#State private var duration_2: TimeInterval = 0
#State private var isHidden_2 = false
var body: some View {
NavigationView {
VStack {
Form {
Button {
withAnimation {
isHidden_1.toggle()
}
} label: {
HStack {
Text("Duration 1:")
Spacer()
Text("\(duration_1)")
}
}
if (!isHidden_1) {
DurationPicker(duration: $duration_1)
}
Button {
withAnimation {
isHidden_2.toggle()
}
} label: {
HStack {
Text("Duration 2:")
Spacer()
Text("\(duration_2)")
}
}
if (!isHidden_2) {
DurationPicker(duration: $duration_2)
}
}
}
.navigationTitle("countDownTimer")
}
}
}
The Issue is that the animation not work correctly. As you can see in the following gif, the animation is very strange. How could this problem be resolved?
My idea is, that the picker expand to the bottom from the Text element. When you click on to button again, the picker should collapse to the top. I am really thankful for any type of help.
For this you can use the transition modifier.
Example:
HStack{
if (!isHidden_1) {
DurationPicker(duration: $duration_1)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
I have added the HStack around the view due to a strange behaviour of .transition. Sometimes it does not work. The HStack or any other container around the view seems to help. If you want to know more about transitions you can read here SwiftuiLabs
Example Gif:

SwiftUI: How Can I Present A View Over All Views? (Especially Sheets)

I want to show Image Viewer over all views when users tap into an image. It's working well without sheets but if there is a sheet on view, the image viewer stays behind it. How can I show image viewer over sheets too? I researched too much but I could not find any solution yet.
ContentView:
#ObservedObject var authVM: AuthVM = .shared
var body: some View {
ZStack{
TabView(selection: self.$authVM.selectedTab) {
HomeTab()
.tabItem {
Image(systemName: "house.fill")
.renderingMode(.template)
Text("Home")
}.tag(SelectedTab.home)
// Other tabs...
}
if self.authVM.showImageViewer{
PhotoViewer(viewerImages: $authVM.images, currentPageIndex: $authVM.imageIndex)
.edgesIgnoringSafeArea(.vertical)
}
}
}
I'm using SKPhotoBrowser pod (UIKit) with UIViewControllerRepresentable, maybe we can do something in UIKit to solve it?
import SwiftUI
import SKPhotoBrowser
struct PhotoViewer: UIViewControllerRepresentable {
#ObservedObject var authVM: AuthVM = .shared
#Binding var viewerImages:[SKPhoto]
#Binding var currentPageIndex: Int
func makeUIViewController(context: Context) -> SKPhotoBrowser {
SKPhotoBrowserOptions.displayHorizontalScrollIndicator = false
let browser = SKPhotoBrowser(photos: viewerImages)
browser.initializePageIndex(currentPageIndex)
browser.delegate = context.coordinator
return browser
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIViewController(_ browser: SKPhotoBrowser, context: Context) {
browser.photos = viewerImages
browser.currentPageIndex = currentPageIndex
}
class Coordinator: NSObject, SKPhotoBrowserDelegate {
var control: PhotoViewer
init(_ control: PhotoViewer) {
self.control = control
}
func didShowPhotoAtIndex(_ browser: PhotoViewer) {
self.control.currentPageIndex = browser.currentPageIndex
}
func didDismissAtPageIndex(_ index: Int) {
self.control.authVM.showImageViewer = false
}
}
}

SwiftUI Full-Screen UIImagePickerController (Camera)

I present a UIImagePickerController within my application by presenting it with logic inside of a sheet modifier. In short, the following three types handle displaying and dismissing a instance of UIImagePickerController inside of a UIViewControllerRepresentable type, which works as expected:
struct DetailsView: View {
enum Sheet: Hashable, Identifiable {
case takePhoto
var id: Int { hashValue }
}
#State private var activeSheet: Sheet?
var body: some View {
Text("Hello, World!")
.sheet(item: $activeSheet) { (sheet) in self.view(for: sheet) }
}
private func view(for sheet: Sheet) -> some View {
switch sheet {
case .takePhoto: return PhotoSelectionView(showImagePicker: .init(get: { sheet == .takePhoto }, set: { (show) in self.activeSheet = show ? .takePhoto : nil }), image: $selectedImage, photoSource: .camera).edgesIgnoringSafeArea(.all)
}
}
}
struct ImagePicker: UIViewControllerRepresentable {
#Binding var isShown: Bool
#Binding var image: Image?
let photoSource: PhotoSource
func makeCoordinator() -> ImagePickerCoordinator {
return ImagePickerCoordinator(isShown: $isShown, selectedImage: $image)
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext<ImagePicker>) {
}
func makeUIViewController(context: UIViewControllerRepresentableContext<ImagePicker>) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
if photoSource == .camera, UIImagePickerController.isSourceTypeAvailable(.camera) {
imagePicker.sourceType = .camera
imagePicker.cameraCaptureMode = .photo
}
imagePicker.delegate = context.coordinator
return imagePicker
}
}
class ImagePickerCoordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
#Binding private var isShown: Bool
#Binding private var selectedImage: Image?
init(isShown: Binding<Bool>, selectedImage: Binding<Image?>) {
_isShown = isShown
_selectedImage = selectedImage
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
// handle photo selection
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
dismiss()
}
}
The issue that I am having is that the camera view is presented modally, which doesn't cover the entire screen. This causes the UIImagePickerController to appear to have broken layouts at times when the camera is the source, as if the camera was not made to be presented in this way. Setting imagePicker.modalPresentationStyle = .fullScreen does not result in a full-screen presentation.
How can I display the camera in a full-screen layout so that it does not appear in the card-like presentation style?
combining #LaX and #GrandSteph's answers, I have:
.fullScreenCover(isPresented: $activeSheet, content: {
ImagePicker(image: $inputImage, sourceType: .camera)
.edgesIgnoringSafeArea(.all)
})
With iOS 14 Apple has added the fullScreenCover(ispresented:ondismiss:content:) and fullScreenCover(item:ondismiss:content:) methods which do exactly what you are requesting.
From your example:
var body: some View {
Text("Hello, World!")
.fullScreenCover(item: $activeSheet) { (sheet) in self.view(for: sheet) }
}
Or, if you simply have a view you want to show:
#State var customViewIsShown = false
// ...
var body: some View {
Text("Hello, World!")
.fullScreenCover(isPresented: $customViewIsShown) {
YourCustomView(isShown: $customViewIsShown)
}
}
Ok, take this answer with a large grain of salt. I'll probably be downvoted to hell because of that and all my coder kids with me ... But so should Apple for not providing an easy way to present full screen a UIImagePickerController in SwiftUI.
The very bad the trick is to create your picker in the rootView and have it ignore safe area. Then you pass all necessary parameters as bindings to the view needing the imagePicker
struct mainView: View {
#State private var imagePicked = UIImage()
#State private var showImagePicker = false
#State private var pickerSource = UIImagePickerController.SourceType.camera
var body: some View {
ZStack {
AvatarView(showImagePicker: self.$showImagePicker, pickerSource: self.$pickerSource , imagePicked: self.$imagePicked)
if self.showImagePicker {
ImagePickerView(isPresented: self.$showImagePicker, selectedImage: self.$imagePicked, source: .camera).edgesIgnoringSafeArea(.all)
}
}
}
}
Of course, your Avatar view will have all the necessary code to update these bindings. Something like this
HStack () {
Button(action: {
self.showActionSheet.toggle()
}) {
MyImageView(image: self.imagePicked)
}
.actionSheet(isPresented: $showActionSheet, content: {
ActionSheet(title: Text("Picture source"), buttons: [
.default(Text("Camera"), action: {
self.pickerSource = .camera
self.showImagePicker.toggle()
}),
.default(Text("Photo Library"), action: {
self.pickerSource = .photoLibrary
self.showImagePicker.toggle()
}),
.destructive(Text("Cancel"))
])
})
}
Honestly I was hesitant to provide this solution, this code makes my eyes bleed but figured someone might have a better idea when reading this and provide a real clean SwiftUI way of doing it.
The good thing about this solution is that it manages well the changes of orientation and let's face it, the UIImagePickerController doesn't fit properly a sheet. It's fine for photo library but not camera.
If you wrap your ImagePicker in a container with black background, that will result in what I think you want to get:
.fullScreenCover(isPresented: $showPhotoPicker) {
ImagePicker(sourceType: .camera, selectedImage: self.$image)
// frame modifier adds a container with given size preferences
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black)
}
where

SwiftUI: Hide Statusbar on NavigationLink destination

I have a master detail structure with a list on master and a detail page where I want to present a webpage fullscreen, so no navigation bar and no status bar. The user can navigate back by a gesture (internal app).
I'm stuggeling hiding the statusbar with
.statusBar(hidden: true)
This works on master page, but not on detail page.
Hiding the navigation bar works fine with my ViewModifier
public struct NavigationAndStatusBarHider: ViewModifier {
#State var isHidden: Bool = false
public func body(content: Content) -> some View {
content
.navigationBarTitle("")
.navigationBarHidden(isHidden)
.statusBar(hidden: isHidden)
.onAppear {self.isHidden = true}
}
}
extension View {
public func hideNavigationAndStatusBar() -> some View {
modifier(NavigationAndStatusBarHider())
}
}
Any idea?
I've been trying this for a couple of hours out of curiosity. At last, I've got it working.
The trick is to hide the status bar in the Main view, whenever the user navigates to the detail view. Here's the code tested in iPhone 11 Pro Max - 13.3 and Xcode version 11.3.1. Hope you like it ;). Happy coding.
import SwiftUI
import UIKit
import WebKit
struct ContentView: View {
var urls: [String] = ["https://www.stackoverflow.com", "https://www.yahoo.com"]
#State private var hideStatusBar = false
var body: some View {
NavigationView {
List {
ForEach(urls, id: \.self) { url in
VStack {
NavigationLink(destination: DetailView(url: url)) {
Text(url)
}
.onDisappear() {
self.hideStatusBar = true
}
.onAppear() {
self.hideStatusBar = false
}
}
}
}
.navigationBarTitle("Main")
}
.statusBar(hidden: hideStatusBar)
}
}
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var url: String = ""
var body: some View {
VStack {
Webview(url: url)
Button("Tap to go back.") {
self.presentationMode.wrappedValue.dismiss()
}
Spacer()
}
.hideNavigationAndStatusBar()
}
}
public struct NavigationAndStatusBarHider: ViewModifier {
#State var isHidden: Bool = false
public func body(content: Content) -> some View {
content
.navigationBarTitle("")
.navigationBarHidden(isHidden)
.statusBar(hidden: isHidden)
.onAppear {self.isHidden = true}
}
}
struct Webview: UIViewRepresentable {
var url: String
typealias UIViewType = WKWebView
func makeUIView(context: UIViewRepresentableContext<Webview>) -> WKWebView {
let wkWebView = WKWebView()
guard let url = URL(string: self.url) else {
return wkWebView
}
let request = URLRequest(url: url)
wkWebView.load(request)
return wkWebView
}
func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext<Webview>) {
}
}
extension View {
public func hideNavigationAndStatusBar() -> some View {
modifier(NavigationAndStatusBarHider())
}
}
Well, your observed behaviour is because status bar hiding does not work being called from inside NavigationView, but works outside. Tested with Xcode 11.2 and(!) Xcode 11.4beta3.
Please see below my findings.
Case1 Case2
Case1: Inside any stack container
struct TestNavigationWithStatusBar: View {
var body: some View {
VStack {
Text("Hello, World!")
.statusBar(hidden: true)
}
}
}
Case2: Inside NavigationView
struct TestNavigationWithStatusBar: View {
var body: some View {
NavigationView {
Text("Hello, World!")
.statusBar(hidden: true)
}
}
}
The solution (fix/workaround) to use .statusBar(hidden:) outside of navigation view. Thus you should update your modifier correspondingly (or rethink design to separate it).
struct TestNavigationWithStatusBar: View {
var body: some View {
NavigationView {
Text("Hello, World!")
}
.statusBar(hidden: true)
}
}
Solution for Xcode 12.5 and IOS 14.6:
Add the following to your Info.plist:
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
Add UIApplication.shared.isStatusBarHidden = false the following to your AppDelegate.swift:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
UIApplication.shared.isStatusBarHidden = false // -> This
return true
}
You can then hide and bring back the status bar anywhere in the app with the UIApplication.shared.isStatusBarHidden modifier.
Example:
struct Example: View {
// I'm checking the safe area below the viewport
// to be able to detect iPhone models without a notch.
#State private var bottomSafeArea = UIApplication.shared.windows.first?.safeAreaInsets.bottom
var body: some View {
Button {
if bottomSafeArea == 0 {
UIApplication.shared.isStatusBarHidden = true // or use .toggle()
}
} label: {
Text("Click here for hide status bar!")
.font(.title2)
}
}
}
I have a NavigationView that contains a view that presents a fullScreenModal. I wanted the status bar visible for the NavigationView, but hidden for the fullScreenModal. I tried multiple approaches but couldn't get anything working (it seems lots of people are finding bugs with the status bar and NavigationView on iOS14).
I've settled on a solution which is hacky but seems to work and should do the job until the bugs are fixed.
Add the following to your Info.plist:
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
And then add the following in your view's init when necessary (changing true/false as required):
UIApplication.shared.isStatusBarHidden = false
For example:
struct ContentView: View {
init() {
UIApplication.shared.isStatusBarHidden = true
}
var body: some View {
Text("Hello, world!")
}
}
It'll give you a deprecated warning, but I'm hoping this is a temporary fix.

Bizarre SwiftUI behavior: ViewModel class + #Binding is breaking when using #Environment(\.presentationMode)

I keep finding very strange SwiftUI bugs that only pop up under very specific circumstances 😅. For example, I have a form that is shown as a model sheet. This form has a ViewModel, and shows a UITextView (via UIViewRepresentable and a #Binding - it's all in the code below).
Everything works absolutely fine, you can run the code below and you'll see all the two-way bindings working as expected: type in one field and it changes in the other, and vice-versa. However, as soon as you un-comment the line #Environment(\.presentationMode) private var presentationMode, then the two-way binding in the TextView breaks. You will also notice that the ViewModel prints "HERE" twice.
What the hell is going on? My guess is that as soon as ContentView shows the modal, the value of presentationMode changes, which then re-renders the sheet (so, FormView). That would explain the duplicate "HERE" getting logged. But, why does that break the two-way text binding?
One workaround is to not use a ViewModel, and simply have an #State property directly in the FormView. But that is not a great solution as I have a bunch of logic in my real-world form, which I don't want to move to the form view. So, does anyone have a better solution?
import SwiftUI
import UIKit
struct TextView: UIViewRepresentable {
#Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let uiTextView = UITextView()
uiTextView.delegate = context.coordinator
return uiTextView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = self.text
}
class Coordinator : NSObject, UITextViewDelegate {
var parent: TextView
init(_ view: TextView) {
self.parent = view
}
func textViewDidChange(_ textView: UITextView) {
self.parent.text = textView.text
}
func textViewDidEndEditing(_ textView: UITextView) {
self.parent.text = textView.text
}
}
}
struct ContentView: View {
#State private var showForm = false
//#Environment(\.presentationMode) private var presentationMode
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView()
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
I finally found a workaround: store the ViewModel on the ContentView, not on the FormView, and pass it in to the FormView.
struct ContentView: View {
#State private var showForm = false
#Environment(\.presentationMode) private var presentationMode
private let viewModel = ViewModel()
var body: some View {
NavigationView {
Text("Hello")
.navigationBarItems(trailing: trailingNavigationBarItem)
}
.sheet(isPresented: $showForm) {
FormView(viewModel: self.viewModel)
}
}
private var trailingNavigationBarItem: some View {
Button("Form") {
self.showForm = true
}
}
}
struct FormView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
Form {
Section(header: Text(viewModel.text)) {
TextView(text: $viewModel.text)
.frame(height: 200)
}
Section(header: Text(viewModel.text)) {
TextField("Text", text: $viewModel.text)
}
}
}
}
}
class ViewModel: ObservableObject {
#Published var text = ""
init() {
print("HERE")
}
}
The only thing is that the ViewModel is now instantiated right when the ContentView is opened, even if you never open the FormView. Feels a bit wasteful. Especially when you have a big List, with NavigationLinks to a bunch of detail pages, which now all create their presented-as-a-sheet FormView's ViewModel up front, even if you never leave the List page.
Sadly I can't turn the ViewModel into a struct, as I actually need to (asynchronously) mutate state and then eventually I run into the Escaping closure captures mutating 'self' parameter compiler error. Sigh. So yeah, I am stuck with using a class.
The root of the issue is still that FormView is instantiated twice (because of #Environment(\.presentationMode)), which causes two ViewModels to be created as well (which my workaround solves by passing in one copy to both FormViews basically). But it's still weird that this broke #Binding, since the standard TextFields did work as expected.
There are still a lot of weird gotcha's like this with SwiftUI, I really hope this becomes simpler to manage soon. If anyone can explain the behavior of sheets, ObservableObject classes (viewmodels), #Environment(\.presentationMode) and #Binding put together, I'm all ears.