Swiftui MACos Scroll a List with Arrow Keys While a Textfield Is Active

SwiftUI how to change state on keyUp macOS event?

Here is a demo of possible approach. Tested with Xcode 11.4 / macOS 10.15.4

The idea is to join custom NSWindow, generating key events, with SwiftUI View via injected publisher to environment values. This gives possibility to listen/handle events at any level of view hierarchy.

Below is full module (AppDelegate.swift) code. Also see useful comments in code.

import Cocoa
import SwiftUI
import Combine

// Environment key to hold even publisher
struct WindowEventPublisherKey: EnvironmentKey {
static let defaultValue: AnyPublisher<NSEvent, Never> =
Just(NSEvent()).eraseToAnyPublisher() // just default stub
}

// Environment value for keyPublisher access
extension EnvironmentValues {
var keyPublisher: AnyPublisher<NSEvent, Never> {
get { self[WindowEventPublisherKey.self] }
set { self[WindowEventPublisherKey.self] = newValue }
}
}

// Custom window holding publisher and sending events to it. In general
// it can be any event, but for originated question we limit to keyUp events
class Window: NSWindow {
private let publisher = PassthroughSubject<NSEvent, Never>() // private

var keyEventPublisher: AnyPublisher<NSEvent, Never> { // public
publisher.eraseToAnyPublisher()
}

override func keyUp(with event: NSEvent) {
publisher.send(event)
}
}

// Root demo view
struct DemoKeyPressedView: View {
@Environment(\.keyPublisher) var keyPublisher // << access to publisher

@State private var index: Int = 0
var body: some View {
Text("Demo \(index)")
.onReceive(keyPublisher) { event in // << listen to events
self.keyPressed(with: event)
}
}

func keyPressed(with event: NSEvent) {
self.index += 1
}
}

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

var window: Window!

func applicationDidFinishLaunching(_ aNotification: Notification) {

// Create the custom window
window = Window(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.center()
window.setFrameAutosaveName("Main Window")

// Create the SwiftUI view that provides the window contents.
let contentView = DemoKeyPressedView()
.frame(minWidth: 400, maxWidth: .infinity, maxHeight: .infinity)
.environment(\.keyPublisher, window.keyEventPublisher) // inject publisher

window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}

func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

How to scroll List programmatically in SwiftUI?

SWIFTUI 2.0

Here is possible alternate solution in Xcode 12 / iOS 14 (SwiftUI 2.0) that can be used in same scenario when controls for scrolling is outside of scrolling area (because SwiftUI2 ScrollViewReader can be used only inside ScrollView)

Note: Row content design is out of consideration scope

Tested with Xcode 12b / iOS 14

demo2

class ScrollToModel: ObservableObject {
enum Action {
case end
case top
}
@Published var direction: Action? = nil
}

struct ContentView: View {
@StateObject var vm = ScrollToModel()

let items = (0..<200).map { $0 }
var body: some View {
VStack {
HStack {
Button(action: { vm.direction = .top }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { vm.direction = .end }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()

ScrollViewReader { sp in
ScrollView {

LazyVStack {
ForEach(items, id: \.self) { item in
VStack(alignment: .leading) {
Text("Item \(item)").id(item)
Divider()
}.frame(maxWidth: .infinity).padding(.horizontal)
}
}.onReceive(vm.$direction) { action in
guard !items.isEmpty else { return }
withAnimation {
switch action {
case .top:
sp.scrollTo(items.first!, anchor: .top)
case .end:
sp.scrollTo(items.last!, anchor: .bottom)
default:
return
}
}
}
}
}
}
}
}

SWIFTUI 1.0+

Here is simplified variant of approach that works, looks appropriate, and takes a couple of screens code.

Tested with Xcode 11.2+ / iOS 13.2+ (also with Xcode 12b / iOS 14)

Demo of usage:

struct ContentView: View {
private let scrollingProxy = ListScrollingProxy() // proxy helper

var body: some View {
VStack {
HStack {
Button(action: { self.scrollingProxy.scrollTo(.top) }) { // < here
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: { self.scrollingProxy.scrollTo(.end) }) { // << here
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
}
Divider()
List {
ForEach(0 ..< 200) { i in
Text("Item \(i)")
.background(
ListScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
}
}
}

demo

Solution:

Light view representable being injected into List gives access to UIKit's view hierarchy. As List reuses rows there are no more values then fit rows into screen.

struct ListScrollingHelper: UIViewRepresentable {
let proxy: ListScrollingProxy // reference type

func makeUIView(context: Context) -> UIView {
return UIView() // managed by SwiftUI, no overloads
}

func updateUIView(_ uiView: UIView, context: Context) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}

Simple proxy that finds enclosing UIScrollView (needed to do once) and then redirects needed "scroll-to" actions to that stored scrollview

class ListScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint) // << bonus !!
}

private var scrollView: UIScrollView?

func catchScrollView(for view: UIView) {
if nil == scrollView {
scrollView = view.enclosingScrollView()
}
}

func scrollTo(_ action: Action) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case .point(let point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: true)
}
}
}

extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}

SwiftUI List with selector: selecting does not work for custom type (macOS)

Add id

List(localSyslogEntries, id: \.self, selection: $selection) { entry in

SwiftUI gap left margin and change color of List item's bottom border

SwiftUI is still missing some features known from UIKit. This will certainly change in the course of time, but for now I would recommend a slightly different approach. You could achieve the desired result by using a ScrollView in combination with a LazyVStack (iOS14)

ScrollView {
LazyVStack(alignment: .leading) {
ForEach(someArray, id: \.self) { _ in
Text("Item")
.padding(.leading)
Divider()
.background(Color.red)
}
}
}

How can I make a UITextField move up when the keyboard is present - on starting to edit?

  1. You will only need a ScrollView if the contents you have now do not fit in the iPhone screen. (If you are adding the ScrollView as the superview of the components just to make the TextField scroll up when keyboard comes up, then it's not needed.)

  2. The standard way to prevent the TextFields from being covered by the keyboard is to move the view up/down whenever the keyboard is shown.

Here is some sample code:

#define kOFFSET_FOR_KEYBOARD 80.0

-(void)keyboardWillShow {
// Animate the current view out of the way
if (self.view.frame.origin.y >= 0)
{
[self setViewMovedUp:YES];
}
else if (self.view.frame.origin.y < 0)
{
[self setViewMovedUp:NO];
}
}

-(void)keyboardWillHide {
if (self.view.frame.origin.y >= 0)
{
[self setViewMovedUp:YES];
}
else if (self.view.frame.origin.y < 0)
{
[self setViewMovedUp:NO];
}
}

-(void)textFieldDidBeginEditing:(UITextField *)sender
{
if ([sender isEqual:mailTf])
{
//move the main view, so that the keyboard does not hide it.
if (self.view.frame.origin.y >= 0)
{
[self setViewMovedUp:YES];
}
}
}

//method to move the view up/down whenever the keyboard is shown/dismissed
-(void)setViewMovedUp:(BOOL)movedUp
{
[UIView beginAnimations:nil context:NULL];
[UIView setAnimationDuration:0.3]; // if you want to slide up the view

CGRect rect = self.view.frame;
if (movedUp)
{
// 1. move the view's origin up so that the text field that will be hidden come above the keyboard
// 2. increase the size of the view so that the area behind the keyboard is covered up.
rect.origin.y -= kOFFSET_FOR_KEYBOARD;
rect.size.height += kOFFSET_FOR_KEYBOARD;
}
else
{
// revert back to the normal state.
rect.origin.y += kOFFSET_FOR_KEYBOARD;
rect.size.height -= kOFFSET_FOR_KEYBOARD;
}
self.view.frame = rect;

[UIView commitAnimations];
}

- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
// register for keyboard notifications
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillShow)
name:UIKeyboardWillShowNotification
object:nil];

[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(keyboardWillHide)
name:UIKeyboardWillHideNotification
object:nil];
}

- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
// unregister for keyboard notifications while not visible.
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIKeyboardWillShowNotification
object:nil];

[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIKeyboardWillHideNotification
object:nil];
}

How to navigate through textfields (Next / Done Buttons)

In Cocoa for Mac OS X, you have the next responder chain, where you can ask the text field what control should have focus next. This is what makes tabbing between text fields work. But since iOS devices do not have a keyboard, only touch, this concept has not survived the transition to Cocoa Touch.

This can be easily done anyway, with two assumptions:

  1. All "tabbable" UITextFields are on the same parent view.
  2. Their "tab-order" is defined by the tag property.

Assuming this you can override textFieldShouldReturn: as this:

-(BOOL)textFieldShouldReturn:(UITextField*)textField
{
NSInteger nextTag = textField.tag + 1;
// Try to find next responder
UIResponder* nextResponder = [textField.superview viewWithTag:nextTag];
if (nextResponder) {
// Found next responder, so set it.
[nextResponder becomeFirstResponder];
} else {
// Not found, so remove keyboard.
[textField resignFirstResponder];
}
return NO; // We do not want UITextField to insert line-breaks.
}

Add some more code, and the assumptions can be ignored as well.

Swift 4.0

 func textFieldShouldReturn(_ textField: UITextField) -> Bool {
let nextTag = textField.tag + 1
// Try to find next responder
let nextResponder = textField.superview?.viewWithTag(nextTag) as UIResponder!

if nextResponder != nil {
// Found next responder, so set it
nextResponder?.becomeFirstResponder()
} else {
// Not found, so remove keyboard
textField.resignFirstResponder()
}

return false
}

If the superview of the text field will be a UITableViewCell then next responder will be

let nextResponder = textField.superview?.superview?.superview?.viewWithTag(nextTag) as UIResponder!


Related Topics



Leave a reply



Submit