SwiftUI TextEditor Scroll with Show Keyboard Not Working - ios

I have a SwiftUI application with SwiftUI lifecycle that uses TextFields and
TextEditors. I have written a modifier to deal with scrolling the content that would
be underneath the keyboard when the keyboard is present. My modifier works as expected
for TextFields but not at all for TextEditors. The Apple docs say the
keyboardWillShowNotification is
posted immediately prior to the display of the keyboard. I cannot find anything which
states the notification is limited to only TextFields. Both field types are in the same ScrollView. Hopefully I'm missing
something simple here.
My modifier:
struct AdaptsToSoftwareKeyboard: ViewModifier {
#State var currentHeight: CGFloat = 0
func body(content: Content) -> some View {
content
.padding(.bottom, self.currentHeight)
.edgesIgnoringSafeArea(self.currentHeight == 0 ? Edge.Set() : .bottom)
.onAppear(perform: subscribeToKeyboardEvents)
}
private let keyboardWillOpen = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect }
.map { $0.height }
private let keyboardWillHide = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in CGFloat.zero }
private func subscribeToKeyboardEvents() {
_ = Publishers.Merge(keyboardWillOpen, keyboardWillHide)
.subscribe(on: RunLoop.main)
.assign(to: \.self.currentHeight, on: self)
}
}
Any guidance would be appreciated. Xcode Version 12.2 beta (12B5018i) iOS 14
First edit: I thought the issue was likely a failure to receive notifications when the TextEditor receives the focus. However, adding this modifier to the TextEditor shows that the notification is fired. No progress:
.onReceive(NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)) { _ in
print("Cursor is in TextEditor!")
}

I assume you should store subscriber as below
struct AdaptsToSoftwareKeyboard: ViewModifier {
#State var currentHeight: CGFloat = 0
#State private var cancelable: AnyCancellable?
func body(content: Content) -> some View {
content
.padding(.bottom, self.currentHeight)
.edgesIgnoringSafeArea(self.currentHeight == 0 ? Edge.Set() : .bottom)
.onAppear(perform: subscribeToKeyboardEvents)
}
private let keyboardWillOpen = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillShowNotification)
.map { $0.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect }
.map { $0.height }
private let keyboardWillHide = NotificationCenter.default
.publisher(for: UIResponder.keyboardWillHideNotification)
.map { _ in CGFloat.zero }
private func subscribeToKeyboardEvents() {
self.cancelable = Publishers.Merge(keyboardWillOpen, keyboardWillHide)
.subscribe(on: RunLoop.main)
.assign(to: \.self.currentHeight, on: self)
}
}

Related

How to open view on notification tapped using SwiftUI with proper hierarchy of views and go back to main application

How can I go to OrderReviewView with ContentViewz on navigation stack hierarchy? So navigation stack looks like ContentView -> OrderReviewView ?
At this moment I managed to open the view, but not in the way I expected. I managed to open the view in several ways, the first of which was using the internal notification NotificationCenter.default.publisher(for: NSNotification.Name("OrderReviewView")) . After receiving it in the ContentView class, I set the variable bool showReviewOrder. The view was opening but I couldn't roll back from the view to the Main class. Another way is presented below, the view also opens, but as in the first case, I can't go back to the main application class. I tried to add NavigationStack by changing the line let host = UIHostingController(rootView: NavigationStack {orderWriteScreen.navigationBarBackButtonHidden(true))} and NavigationLink to the OrderReviewScreen and structure as follow
NavigationStack {
ZStack {
VStack(alignment: .leading){
Text("Test")
}
NavigationLink(destination: ContentView())
{FAB() }
.padding(EdgeInsets(top: 0, leading: 0, bottom: 50, trailing: 20))
.frame(maxHeight: .infinity, alignment: .bottom)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
I was able to go back to the main application class, but there was a back button at the top of the screen and the hierarchy was still one window too many. I am attaching minimal working example of my code to show the problem.
#main
struct App: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
NavigationStack {
if authManager.authorized == nil {
SignInScreen()
} else {
Main()
}
}
}
}
struct Main: View {
var body: some View {
TabBar()
}
}
extension TabBar {
var baseTabBar: some View {
TabView(selection: $selection) {
OrderScreen()
.tag(Tab.contracts)
}
.padding(.bottom, _displayTabBar ? tabBarHeight : 0)
}
}
struct TabBar: View {
let tabBarHeight: CGFloat = 55
#State private var selection = Tab.farms
#State var _displayTabBar = true
init() {
UITabBar.appearance().isHidden = true
}
var body: some View {
ZStack(alignment: .bottom) {
baseTabBar
.environment(\.displayTabBar, displayTabBar)
if (_displayTabBar) {
styledTabBar
}
}
}
}
struct OrderScreen: View {
var body: some View {
ZStack {
VStack(alignment: .leading) {
}
}
}
struct OrderReviewScreen: View {
var body: some View {
VStack(alignment: .leading) {
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
FirebaseConfiguration.shared.setLoggerLevel(.debug)
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions) { _, _ in }
application.registerForRemoteNotifications()
Messaging.messaging().delegate = self
return true
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: #escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let info = userInfo as NSDictionary
guard let screen = info.value(forKey: "screen") as? String else { return }
guard let id = info.value(forKey: "id") as? String else { return }
notificationManager?.notifyId = id
notificationManager?.notifyScreen = screen
if screen == "order_details" {
let orderWriteScreen = OrderReviewScreen()
let host = UIHostingController(rootView: orderWriteScreen.navigationBarBackButtonHidden(true))
UIApplication.shared.windows.first?.rootViewController = host
}
completionHandler()
}
}
}

SwiftUI - Showing and hiding a custom alert

I am creating a custom alert reusable modifier which will have different number of buttons depending upon the style I need. Anyways in my sample code below, I have removed as much extra code as I can to keep it small and simple.
With the code below, I am able to show the alert, but clicking on "Click Me" button does not dismiss the alert. I feel something is wrong with the way I am setting "isPresented" binding variable, but not able to figure out what. Am new to SwiftUI.
Custom Alert Modifier:
struct CustomAlertView: ViewModifier {
var isPresented: Binding<Bool>
init(isPresented: Binding<Bool>) {
self.isPresented = isPresented
}
func body(content: Content) -> some View {
content.overlay(alertContent())
}
#ViewBuilder
private func alertContent() -> some View {
GeometryReader { geometry in
if self.isPresented {
// Do something
// Setting isPresented to false is not doing anything when button is clicked.
Button(action: { self.isPresented.wrappedValue = false }) {
Text("Click Me")
}
}
}
}
}
func customAlert(isPresented: Binding<Bool>) -> some View {
return modifier(CustomAlertView(isPresented: isPresented))
}
View and view model code:
struct MyView: View {
#ObservedObject var viewModel: CustomViewModel = CustomViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .idle:
Color.clear.onAppear(perform: { viewModel.doSomething() })
case .showNewView:
// Navigate
}
}.onAppear() {
viewModel.state = .idle
}.customAlert(isPresented: .constant(viewModel.showAlert))
}
}
#MainActor
class CustomViewModel: ObservableObject {
enum State {
case idle
case showNewView
}
#Published var state = State.idle
#Published var showAlert = false
func doSomething() {
if failure {
self.showAlert = true
} else {
self.state = .showNewView
}
}
}
What am I doing wrong or why am I not able to dismiss the alert?
Thanks for looking!
First of all, you want #Binding var isPresented: Bool inside the CustomAlertView, and you assign it in init as self._isPresented = isPresented:
struct CustomAlertView: ViewModifier {
#Binding var isPresented: Bool // <-- like this
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented // <-- and like this
}
//...
And second, you definitely don't want to set isPresented to a constant in .customAlert(isPresented: .constant(viewModel.showAlert)). Instead, you need to pass the binding variable:
VStack {
// ...
}
.customAlert(isPresented: $viewModel.showAlert) // <-- bind it here

Running code when SwiftUI Toggle Changes Value

The general structure of my code is that I have a UIKit app in which I am trying to embed a swiftui view. So I have a file called SettingsViewController which is as follows:
class SettingsViewController: UIViewController {
...
var items: [(SettingsView.Setting)] = ...
var actionsForItems: [() -> Void = []
#State var isOn: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
actionsForItems = ...
...
addSettingCell(isOn: isOn)
let childView = UIHostingController(rootView: SettingsView(settings: items, actionsForSettings: actionsForItems))
addChild(childView)
childView.view.frame = container.bounds
container.addSubview(childView.view)
childView.didMove(toParent: self)
}
...
func addCell(isOn: Bool) {
items.insert((settingName, $isOn as AnyObject) as SettingsView.Setting)
actionsForItems.insert({
self.onSwitchValueChanged(isOn: isOn) //defined later
})
}
}
which creates a view called Settings View which is structured as follows:
struct SettingsView: View {
typealias Setting = (key: String, value: AnyObject?)
var settings: [Setting]
var actions: [() -> Void]
var body: some View {
List(settings.indices) { index in
...
SettingsWithSwitchView(setting: settings[index], action: actions[index], isOn: setting.value as Binding<Bool>)
}
Spacer()
}
}
and SettingsWithSwitchView is as follows:
struct SettingsWithSwitchView: View {
var setting: SettingsView.Setting
var action: () -> Void
#Binding var isOn: Bool {
willSet {
print("willSet: newValue =\(newValue) oldValue =\(isOn)")
}
didSet {
print("didSet: oldValue=\(oldValue) newValue=\(isOn)")
action()
}
}
var body: some View {
HStack {
Text(setting.key)
.foregroundColor(Color("GrayText"))
.font(Font.custom("OpenSans", size: 15))
Spacer()
Toggle(isOn: $isOn) {}
}
}
}
I read in another post here on Stack Overflow that calling didSet on the isOn property should be the way to accomplish this, but I need to call onSwitchValueChanged when the Toggle value is updated, but my current setup does not work. I would really appreciate some help to figure out how to do this. I can update with some other information if necessary.
The thing that ended up working for me was creating a ViewModel which was also an ObservableObject and then setting the action for the toggle inside of .onTapGesture

How to get a view to update automatically after period of inactivity?

I am trying to add a timeout feature to my SwiftUI app. The view should be updated when timeout is reached. I have found code on a different thread, which works for the timeout part, but I cannot get the view to update.
I am using a static property in the UIApplication extension to toggle the timeout flag. Looks like the view is not notified when this static property changes. What is the correct way to do this?
Clarification added:
#workingdog has proposed an answer below. This does not quite work, because in the actual app, there is not just one view, but multiple views that the user can navigate between. So, I am looking for a global timer that gets reset by any touch action whatever the current view is.
In the sample code, the global timer works, but the view does not take notice when the static var UIApplication.timeout is changed to true.
How can I get the view to update? Is there something more appropriate for this purpose than a static var? Or maybe the timer should not be in the UIApplication extension to begin with?
Here is my code:
import SwiftUI
#main
struct TimeoutApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: UIApplication.shared.addTapGestureRecognizer)
}
}
}
extension UIApplication {
private static var timerToDetectInactivity: Timer?
static var timeout = false
func addTapGestureRecognizer() {
guard let window = windows.first else { return }
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapped))
tapGesture.requiresExclusiveTouchType = false
tapGesture.cancelsTouchesInView = false
tapGesture.delegate = self
window.addGestureRecognizer(tapGesture)
}
private func resetTimer() {
let showScreenSaverInSeconds: TimeInterval = 5
if let timerToDetectInactivity = UIApplication.timerToDetectInactivity {
timerToDetectInactivity.invalidate()
}
UIApplication.timerToDetectInactivity = Timer.scheduledTimer(
timeInterval: showScreenSaverInSeconds,
target: self,
selector: #selector(timeout),
userInfo: nil,
repeats: false
)
}
#objc func timeout() {
print("Timeout")
Self.timeout = true
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
if !Self.timeout {
print("Tapped")
self.resetTimer()
}
}
}
extension UIApplication: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Text(UIApplication.timeout ? "TimeOut" : "Hello World!")
.padding()
Button("Print to Console") {
print(UIApplication.timeout ? "Timeout reached, why is view not updated?" : "Hello World!")
}
}
}
}
to update the view when timeout is reached, you could do something like this:
import SwiftUI
#main
struct TimeoutApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var thingToUpdate = "tap the screen to rest the timer"
#State var timeRemaining = 5
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack (spacing: 30) {
Text("\(thingToUpdate)")
Text("\(timeRemaining)")
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
} else {
thingToUpdate = "refreshed, tap the screen to rest the timer"
}
}
}
.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
thingToUpdate = "tap the screen to rest the timer"
timeRemaining = 5
}
}
}
You can do this like this - >
Create a subclass of "UIApplication" (use separate files like
MYApplication.swift).
Use the below code in the file.
Now method "idle_timer_exceeded" will get called once the user stops
touching the screen.
Use notification to update the UI
import UIKit
import Foundation
private let g_secs = 5.0 // Set desired time
class MYApplication: UIApplication
{
var idle_timer : dispatch_cancelable_closure?
override init()
{
super.init()
reset_idle_timer()
}
override func sendEvent( event: UIEvent )
{
super.sendEvent( event )
if let all_touches = event.allTouches() {
if ( all_touches.count > 0 ) {
let phase = (all_touches.anyObject() as UITouch).phase
if phase == UITouchPhase.Began {
reset_idle_timer()
}
}
}
}
private func reset_idle_timer()
{
cancel_delay( idle_timer )
idle_timer = delay( g_secs ) { self.idle_timer_exceeded() }
}
func idle_timer_exceeded()
{
// You can broadcast notification here and use an observer to trigger the UI update function
reset_idle_timer()
}
}
You can view the source for this post here.
Here is my code for a "global" timer using the "Environment". Although it works for me, it seems to be a lot of
work just to do something simple. This leads me to believe there must be
a better way to do this. Anyhow, there maybe some ideas you can recycle here.
import SwiftUI
#main
struct TestApp: App {
#StateObject var globalTimer = MyGlobalTimer()
var body: some Scene {
WindowGroup {
ContentView().environment(\.globalTimerKey, globalTimer)
}
}
}
struct MyGlobalTimerKey: EnvironmentKey {
static let defaultValue = MyGlobalTimer()
}
extension EnvironmentValues {
var globalTimerKey: MyGlobalTimer {
get { return self[MyGlobalTimerKey] }
set { self[MyGlobalTimerKey] = newValue }
}
}
class MyGlobalTimer: ObservableObject {
#Published var timeRemaining: Int = 5
var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
func reset(_ newValue: Int = 5) {
timeRemaining = newValue
}
}
struct ContentView: View {
#Environment(\.globalTimerKey) var globalTimer
#State var refresh = true
var body: some View {
GlobalTimerView { // <-- this puts the content into a tapable view
// the content
VStack {
Text(String(globalTimer.timeRemaining))
.accentColor(refresh ? .black :.black) // <-- trick to refresh the view
.onReceive(globalTimer.timer) { _ in
if globalTimer.timeRemaining > 0 {
globalTimer.timeRemaining -= 1
refresh.toggle() // <-- trick to refresh the view
print("----> time remaining: \(globalTimer.timeRemaining)")
} else {
// do something at every time step after the countdown
print("----> do something ")
}
}
}
}
}
}
struct GlobalTimerView<Content: View>: View {
#Environment(\.globalTimerKey) var globalTimer
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack {
content
Color(white: 1.0, opacity: 0.001)
.frame(minWidth: 0, idealWidth: .infinity, maxWidth: .infinity,
minHeight: 0, idealHeight: .infinity, maxHeight: .infinity,
alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
globalTimer.reset()
}
}.onAppear {
globalTimer.reset()
}
}
}

Avoiding Keyboard in SwifUI with TextEditor

I'm attempting to recreate a simple version of the iOS notes app. Mind you, I'm a complete Swift novice. My current issue is that I want my view to move up as the keyboard appears. I've implemented some code that does do this, but it has some nasty bugs. It first moves the view up WAY too high, then when you begin typing, the view is where it should be. Here are some photos for representation, as well as my code:
Before the keyboard appears
When the keyboard first appears
Once you begin typing
Code:
class KeyboardResponder: ObservableObject {
#Published var currentHeight: CGFloat = 0
var _center: NotificationCenter
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
withAnimation {
currentHeight = keyboardSize.height
print("KEYBOARDSIZE.HEIGHT IN OBSERVER: \(keyboardSize.height)")
}
}
print("KEYBOARD HEIGHT IN OBSERVER: \(currentHeight)")
}
#objc func keyBoardWillHide(notification: Notification) {
withAnimation {
currentHeight = 0
}
}
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
}
First, there is a KeyboardResponder class, listening for keyboard appearances and disappearances (with a published variable for its height).
VStack {
TextEditor(text: $content).padding(.all).foregroundColor(fontColors[self.color]).font(fontStyles[self.font])
HStack {
Spacer()
Button(action: {
self.show = false
}) {
Text("Cancel").foregroundColor(.gray).font(.headline)
}
Spacer()
Button(action: {
self.showPanel = true
}) {
Image(systemName: "textformat").font(.headline).foregroundColor(.white).padding(.all)
}.background(Color.green).clipShape(Circle())
Spacer()
Button(action: {
self.show.toggle()
self.saveData()
}) {
Text("Save").foregroundColor(Color(UIColor.systemBlue)).font(.headline)
}
Spacer()
}
}.padding(.bottom, keyboardResponder.currentHeight)
This is the view, with the editor shown in the photos. At the top of this view I have #ObservedObject var keyboardResponder = KeyboardResponder(). I've tried both .padding(.bottom, keyboardResponder.currentHeight) as well as .offset(y: -keyboardResponder.currentHeight). Anyone know what's going on?
Solution Found:
I finally found a solution that works! I got this code from
https://augmentedcode.io/2020/03/29/revealing-content-behind-keyboard-in-swiftui/
fileprivate final class KeyboardObserver: ObservableObject {
struct Info {
let curve: UIView.AnimationCurve
let duration: TimeInterval
let endFrame: CGRect
}
private var observers = [NSObjectProtocol]()
init() {
let handler: (Notification) -> Void = { [weak self] notification in
self?.keyboardInfo = Info(notification: notification)
}
let names: [Notification.Name] = [
UIResponder.keyboardWillShowNotification,
UIResponder.keyboardWillHideNotification,
UIResponder.keyboardWillChangeFrameNotification
]
observers = names.map({ name in
NotificationCenter.default.addObserver(forName: name,
object: nil,
queue: .main,
using: handler)
})
}
#Published var keyboardInfo = Info(curve: .linear, duration: 0, endFrame: .zero)
}
fileprivate extension KeyboardObserver.Info {
init(notification: Notification) {
guard let userInfo = notification.userInfo else { fatalError() }
curve = {
let rawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! Int
return UIView.AnimationCurve(rawValue: rawValue)!
}()
duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! TimeInterval
endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
}
}
struct KeyboardVisibility: ViewModifier {
#ObservedObject fileprivate var keyboardObserver = KeyboardObserver()
func body(content: Content) -> some View {
GeometryReader { geometry in
withAnimation() {
content.padding(.bottom, max(0, self.keyboardObserver.keyboardInfo.endFrame.height - geometry.safeAreaInsets.bottom))
.animation(Animation(keyboardInfo: self.keyboardObserver.keyboardInfo))
}
}
}
}
fileprivate extension Animation {
init(keyboardInfo: KeyboardObserver.Info) {
switch keyboardInfo.curve {
case .easeInOut:
self = .easeInOut(duration: keyboardInfo.duration)
case .easeIn:
self = .easeIn(duration: keyboardInfo.duration)
case .easeOut:
self = .easeOut(duration: keyboardInfo.duration)
case .linear:
self = .linear(duration: keyboardInfo.duration)
#unknown default:
self = .easeInOut(duration: keyboardInfo.duration)
}
}
}
extension View {
func keyboardVisibility() -> some View {
return modifier(KeyboardVisibility())
}
}
Then just add the modifier to the view you want to move up with the keyboard like so .keyboardVisibility()
add this line in the VStack where you add bottom padding, cause that vStack take padding from the safe area( after keyboard appear the key board area is also belongs to safe area)
.edgesIgnoringSafeArea(.bottom)

Resources