SwiftUI Searchbar pushed off screen by keyboard - list

I have implemented a searchbar that filters a list. However when the keyboard appears it pushes the searchbar right off the screen. I have tried using .ignoresSafeArea(.keyboard) however it will not work (I have tried placing it in many different spots). I would like to make it so the view/list does not move at all when the keyboard appears.
I am displaying this view below after pressing a button
//MARK: - ActivitySelectorView
ActivitySelectorView(showActivitySelector: $showActivitySelector, activityToSave: activityToSave, allActivities: activities, categoryNames: categoryNames)
.environmentObject(activityToSave)
.frame(width: screen.width, height: screen.height)
.offset(x: showActivitySelector ? 0 : screen.width)
.offset(y: screen.minY)
.offset(x: viewState.width)
.animation(.easeInOut)
And inside ActivitySelectorView I have a title bar and the filtered list which includes a searchbar and list
var body: some View {
ZStack {
//backgroundColor
Color("\(activityToSave.category)Color")
VStack {
TitleBar(showingAlert: $showingAlert, showActivitySelector: $showActivitySelector, categoryName: categoryNames[(Int(String(activityToSave.category.last!)) ?? 1) - 1])
//MARK: - LIST
FilteredList(filter: activityToSave.category, passedActivityBinding: $activityToSave.activityName, showActivitySelector: $showActivitySelector)
.colorMultiply(Color("\(activityToSave.category)Color"))
}
}
}
Here we have FilteredList:
var body: some View {
List {
SearchBar(text: $searchText)
ForEach(fetchRequest.wrappedValue.filter({ searchText.isEmpty ? true : $0.name.contains(searchText) }), id: \.self) { activity in
Text(activity.name.capitalized)
.onTapGesture {
self.showActivitySelector = false
self.selectedActivity = activity.name.capitalized
}
}.onDelete(perform: deleteActivity)
}
.resignKeyboardOnDragGesture()
}
And last the code for the searchBar
struct SearchBar: UIViewRepresentable {
#Binding var text: String
class Coordinator: NSObject, UISearchBarDelegate {
#Binding var text: String
init(text: Binding<String>) {
_text = text
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
UIApplication.shared.endEditing()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: Context) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
searchBar.returnKeyType = .done
searchBar.enablesReturnKeyAutomatically = false
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
uiView.text = text
}
}

Related

swiftui ios15 keyboard avoidance issue on custom textfield

Repost question from this Adjust View up with Keyboard show in SwiftUI 3.0 iOS15.
SwiftUI keyboard avoidance won't show the whole textfield including the overlay.
I already tried a lot different ways from googling.
Does anyone have any solution for this?
struct ContentView: View {
#State var text: String = ""
var body: some View {
ScrollView {
VStack {
Spacer(minLength: 600)
TextField("Placeholder", text: $text)
.textFieldStyle(CustomTextFieldStyle())
}
}
}
}
struct CustomTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding(10)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.red, lineWidth: 5)
)
}
}
You can write the custom UITextFeild, in which the intrinsicContentSize will be overridden.
Example:
final class _UITextField: UITextField {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: 56)
}
}
Then, you can write your own implementation of TextField, using UIViewRepresentable protocol and UITextFieldDelegate:
struct _TextField: UIViewRepresentable {
private let title: String?
#Binding var text: String
let textField = _UITextField()
init(
_ title: String?,
text: Binding<String>
) {
self.title = title
self._text = text
}
func makeCoordinator() -> _TextFieldCoordinator {
_TextFieldCoordinator(self)
}
func makeUIView(context: Context) -> _UITextField {
textField.placeholder = title
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ uiView: _UITextField, context: Context) {}
}
final class _TextFieldCoordinator: NSObject, UITextFieldDelegate {
private let control: _TextField
init(_ control: _TextField) {
self.control = control
super.init()
control.textField.addTarget(self, action: #selector(textFieldEditingChanged), for: .editingChanged)
}
#objc private func textFieldEditingChanged(_ textField: UITextField) {
control.text = textField.text ?? ""
}
}

SwiftUI keyboard toolbar. Is it possible to use it with UITexField

I have new SwiftUI .keyboard toolbar added. And it works great with Swiftui TextFields. But I consider if it is possible and how can it be done to use this toolbar also with UITextFields wrapped in UIViewRepresentable. I don’t know if I am doing something wrong or this isn’t supported.
I had the same problem and couldn't find an answer, so I tried to recreate this keyboard toolbar in SwiftUI. Here is the code:
struct SomeView: View {
#State var text = ""
#State var focusedUITextField = false
var body: some View {
NavigationStack {
ZStack {
VStack {
Button("Remove UITextField focus") {
focusedUITextField = false
}
TextField("SwiftUI", text: $text)
CustomTextField(hint: "UIKit", text: $text, focused: $focusedUITextField)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal)
if focusedUITextField {
VStack(spacing: 0) {
Spacer()
Divider()
keyboardToolbarContent
.frame(maxWidth: .infinity, maxHeight: 44)
.background(Color(UIColor.secondarySystemBackground))
}
}
}
/*
.toolbar {
ToolbarItem(placement: .keyboard) {
keyboardToolbarContent
}
}
*/
}
}
var keyboardToolbarContent: some View {
HStack {
Rectangle()
.foregroundColor(.red)
.frame(width: 50, height: 40)
Text("SwiftUI stuff")
}
}
}
And for the custom UITextField:
struct CustomTextField: UIViewRepresentable {
let hint: String
#Binding var text: String
#Binding var focused: Bool
func makeUIView(context: Context) -> UITextField {
let uiTextField = UITextField()
uiTextField.delegate = context.coordinator
uiTextField.placeholder = hint
return uiTextField
}
func updateUIView(_ uiTextField: UITextField, context: Context) {
uiTextField.text = text
uiTextField.placeholder = hint
if focused {
uiTextField.becomeFirstResponder()
} else {
uiTextField.resignFirstResponder()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: NSObject, UITextFieldDelegate {
let parent: CustomTextField
init(parent: CustomTextField) {
self.parent = parent
}
func textFieldDidBeginEditing(_ textField: UITextField) {
parent.focused = true
}
func textFieldDidEndEditing(_ textField: UITextField) {
parent.focused = false
}
}
}
Unfortunately the animation of the custom toolbar and the keyboard don't match perfectly. I would challenge the guy in the comments from this post How to add a keyboard toolbar in SwiftUI that remains even when keyboard not visible but sadly I can't comment.
Also the background color doesn't match the native toolbar exactly, I don't know which color is used there.

View automatically jumps to Multiline Text Field

My SwiftUI View is kinda acting weird since i added a MultilineTextField. When pressing a item on the List, the view kind jumps back and forth and then it jumps automatically to the last text field in the view as seen in this video. This just happened after i added a MultilineTextField at the end.
MultilineTextField definition:
import Foundation
import SwiftUI
import UIKit
fileprivate struct UITextViewWrapper: UIViewRepresentable {
typealias UIViewType = UITextView
#Binding var text: String
#Binding var calculatedHeight: CGFloat
var onDone: (() -> Void)?
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textField = UITextView()
textField.delegate = context.coordinator
textField.isEditable = true
textField.font = UIFont.preferredFont(forTextStyle: .body)
textField.isSelectable = true
textField.isUserInteractionEnabled = true
textField.isScrollEnabled = false
textField.backgroundColor = UIColor.clear
if nil != onDone {
textField.returnKeyType = .done
}
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textField
}
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if uiView.text != self.text {
uiView.text = self.text
}
if uiView.window != nil, !uiView.isFirstResponder {
uiView.becomeFirstResponder()
}
UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
}
fileprivate static func recalculateHeight(view: UIView, result: Binding<CGFloat>) {
let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
if result.wrappedValue != newSize.height {
DispatchQueue.main.async {
result.wrappedValue = newSize.height // !! must be called asynchronously
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
var calculatedHeight: Binding<CGFloat>
var onDone: (() -> Void)?
init(text: Binding<String>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
self.text = text
self.calculatedHeight = height
self.onDone = onDone
}
func textViewDidChange(_ uiView: UITextView) {
text.wrappedValue = uiView.text
UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if let onDone = self.onDone, text == "\n" {
textView.resignFirstResponder()
onDone()
return false
}
return true
}
}
}
struct MultilineTextField: View {
private var placeholder: String
private var onCommit: (() -> Void)?
#Binding private var text: String
private var internalText: Binding<String> {
Binding<String>(get: { self.text } ) {
self.text = $0
self.showingPlaceholder = $0.isEmpty
}
}
#State private var dynamicHeight: CGFloat = 100
#State private var showingPlaceholder = false
init (_ placeholder: String = "", text: Binding<String>, onCommit: (() -> Void)? = nil) {
self.placeholder = placeholder
self.onCommit = onCommit
self._text = text
self._showingPlaceholder = State<Bool>(initialValue: self.text.isEmpty)
}
var body: some View {
UITextViewWrapper(text: self.internalText, calculatedHeight: $dynamicHeight, onDone: onCommit)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.background(placeholderView, alignment: .topLeading)
}
var placeholderView: some View {
Group {
if showingPlaceholder {
Text(placeholder).foregroundColor(.gray)
.padding(.leading, 4)
.padding(.top, 8)
}
}
}
}
#if DEBUG
struct MultilineTextField_Previews: PreviewProvider {
static var test:String = ""//some very very very long description string to be initially wider than screen"
static var testBinding = Binding<String>(get: { test }, set: {
// print("New value: \($0)")
test = $0 } )
static var previews: some View {
VStack(alignment: .leading) {
Text("Description:")
MultilineTextField("Enter some text here", text: testBinding, onCommit: {
print("Final text: \(test)")
})
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black))
Text("Something static here...")
Spacer()
}
.padding()
}
}
#endif
Code:
struct DetailZwei : View {
#State var data : dataTypeZwei
#State var viewModel = GerätEditieren()
#Environment(\.presentationMode) var presentationMode
#State private var showingAlert = false
var body : some View {
NavigationView {
ScrollView {
VStack {
Group {
Section(header: Text("")) {
Text("Seriennummer")
TextField("Seriennummer", text: $data.sn).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Objekt")
TextField("Objekt", text: $data.objekt).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Gerätetyp")
TextField("Gerätetyp", text: $data.typ).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Geräteposition")
TextField("Geräteposition", text: $data.pos).textFieldStyle(RoundedBorderTextFieldStyle())
}
Group {
Text("Installationsdatum")
TextField("Installationsdatum", text: $data.ida).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Leasing oder Gekauft?")
TextField("Leasing oder Gekauft?", text: $data.lg).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Ablaufdatum Leasing")
TextField("Ablaufdatum Leasing", text: $data.la).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Ablaufdatum Garantie")
TextField("Ablaufdatum Garantie", text: $data.ga).textFieldStyle(RoundedBorderTextFieldStyle())
}
Section(header: Text("")) {
Text("Strasse")
TextField("Strasse", text: $data.str).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Hausnummer")
TextField("Hausnummer", text: $data.nr).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Postleitzahl")
TextField("Postleitzahl", text: $data.plz).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Ort")
TextField("Ort", text: $data.ort).textFieldStyle(RoundedBorderTextFieldStyle())
}
Section(header: Text("")) {
Text("Ansprechperson")
TextField("Ansprechperson", text: $data.vp).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Telefonnummer")
TextField("Telefonnummer", text: $data.tel).textFieldStyle(RoundedBorderTextFieldStyle())
}
Section(header: Text("VDS").bold()) {
Text("Eingetragen durch")
TextField("Eingetragen durch", text: $data.ed).textFieldStyle(RoundedBorderTextFieldStyle())
Text("Lieferdatum VDS")
TextField("Lieferdatum VDS", text: $data.ldvds).textFieldStyle(RoundedBorderTextFieldStyle())
}
// This is the Text Field
Section(header: Text("")) {
Text("Zusätzliche Informationen")
MultilineTextField("Zusätzliche Informationen", text: $data.zusatz).overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black))
}
}.padding()
.navigationBarTitle("Gerät bearbeiten", displayMode: .inline)
.navigationBarItems(leading: Button(action: { self.handleCancelTapped() }, label: {
Text("Abbrechen")
}),
trailing: Button(action: { self.handleDoneTapped() }, label: {
Text("Speichern")
})
// .disabled(!viewModel.modified)
)
}.alert(isPresented: $showingAlert) {
Alert(title: Text("Änderungen gespeichert"), message: Text("Die Änderungen vom Gerät \(data.sn) wurden erfolgreich gespeichert!"), dismissButton: .default(Text("Zurück").bold()){
self.handleCancelTapped()
})
}
}
}
}
When you opening the view, your Custom TextField calls firstResponders. Just remove calling firstResponder on load and your view will start at the beginning.
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if uiView.text != self.text {
uiView.text = self.text
}
if uiView.window != nil, !uiView.isFirstResponder {
//uiView.becomeFirstResponder() << Here calling firstResponder
}
UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
}
SwiftUI TextFields do not support firstResponder yet, however with Representable and using UIKit it is possible, like in your solution, Grüezi

SwiftUI chat app: the woes of reversed List and Context Menu

I am building a chat app in SwiftUI. To show messages in a chat, I need a reversed list (the one that shows most recent entries at the bottom and auto-scrolls to the bottom). I made a reversed list by flipping both the list and each of its entries (the standard way of doing it).
Now I want to add Context Menu to the messages. But after the long press, the menu shows messages flipped. Which I suppose makes sense since it plucks a flipped message out of the list.
Any thoughts on how to get this to work?
import SwiftUI
struct TestView: View {
var arr = ["1aaaaa","2bbbbb", "3ccccc", "4aaaaa","5bbbbb", "6ccccc", "7aaaaa","8bbbbb", "9ccccc", "10aaaaa","11bbbbb", "12ccccc"]
var body: some View {
List {
ForEach(arr.reversed(), id: \.self) { item in
VStack {
Text(item)
.height(100)
.scaleEffect(x: 1, y: -1, anchor: .center)
}
.contextMenu {
Button(action: { }) {
Text("Reply")
}
}
}
}
.scaleEffect(x: 1, y: -1, anchor: .center)
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
The issue with flipping is that you need to flip the context menu and SwiftUI does not give this much control.
The better way to handle this is to get access to embedded UITableView(on which you will have more control) and you need not add additional hacks.
Here is the demo code:
import SwiftUI
import UIKit
struct TestView: View {
#State var arr = ["1aaaaa","2bbbbb", "3ccccc", "4aaaaa","5bbbbb", "6ccccc", "7aaaaa","8bbbbb", "9ccccc", "10aaaaa","11bbbbb", "12ccccc"]
#State var tableView: UITableView? {
didSet {
self.tableView?.adaptToChatView()
DispatchQueue.main.asyncAfter(deadline: .now()) {
self.tableView?.scrollToBottom(animated: true)
}
}
}
var body: some View {
NavigationView {
List {
UIKitView { (tableView) in
DispatchQueue.main.async {
self.tableView = tableView
}
}
ForEach(arr, id: \.self) { item in
Text(item).contextMenu {
Button(action: {
// change country setting
}) {
Text("Choose Country")
Image(systemName: "globe")
}
Button(action: {
// enable geolocation
}) {
Text("Detect Location")
Image(systemName: "location.circle")
}
}
}
}
.navigationBarTitle(Text("Chat View"), displayMode: .inline)
.navigationBarItems(trailing:
Button("add chat") {
self.arr.append("new Message: \(self.arr.count)")
self.tableView?.adaptToChatView()
DispatchQueue.main.async {
self.tableView?.scrollToBottom(animated: true)
}
})
}
}
}
extension UITableView {
func adaptToChatView() {
let offset = self.contentSize.height - self.visibleSize.height
if offset < self.contentOffset.y {
self.tableHeaderView = UIView.init(frame: CGRect.init(x: 0, y: 0, width: self.contentSize.width, height: self.contentOffset.y - offset))
}
}
}
extension UIScrollView {
func scrollToBottom(animated:Bool) {
let offset = self.contentSize.height - self.visibleSize.height
if offset > self.contentOffset.y {
self.setContentOffset(CGPoint(x: 0, y: offset), animated: animated)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
final class UIKitView : UIViewRepresentable {
let callback: (UITableView) -> Void //return TableView in CallBack
init(leafViewCB: #escaping ((UITableView) -> Void)) {
callback = leafViewCB
}
func makeUIView(context: Context) -> UIView {
let view = UIView.init(frame: CGRect(x: CGFloat.leastNormalMagnitude,
y: CGFloat.leastNormalMagnitude,
width: CGFloat.leastNormalMagnitude,
height: CGFloat.leastNormalMagnitude))
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if let tableView = uiView.next(UITableView.self) {
callback(tableView) //return tableview if find
}
}
}
extension UIResponder {
func next<T: UIResponder>(_ type: T.Type) -> T? {
return next as? T ?? next?.next(type)
}
}
You can create a custom modal for reply and show it with long press on every element of the list without showing contextMenu.
#State var showYourCustomReplyModal = false
#GestureState var isDetectingLongPress = false
var longPress: some Gesture {
LongPressGesture(minimumDuration: 0.5)
.updating($isDetectingLongPress) { currentstate, gestureState,
transaction in
gestureState = currentstate
}
.onEnded { finished in
self.showYourCustomReplyModal = true
}
}
Apply it like:
ForEach(arr, id: \.self) { item in
VStack {
Text(item)
.height(100)
.scaleEffect(x: 1, y: -1, anchor: .center)
}.gesture(self.longPress)
}
As of iOS 14, SwiftUI has ScrollViewReader which can be used to position the scrolling. GeometryReader along with minHeight and Spacer() can make a VStack that uses the full screen while displaying messages starting at the bottom. Items are read from and appended to an array in the usual first-in first-out order.
SwiftUI example:
struct ContentView: View {
#State var items: [Item] = []
#State var text: String = ""
#State var targetItem: Item?
var body: some View {
VStack {
ScrollViewReader { scrollView in
ChatStyleScrollView() {
ForEach(items) { item in
ItemView(item: item)
.id(item.id)
}
}
.onChange(of: targetItem) { item in
if let item = item {
withAnimation(.default) {
scrollView.scrollTo(item.id)
}
}
}
TextEntryView(items: $items, text: $text, targetItem: $targetItem)
}
}
}
}
//MARK: - Item Model with unique identifier
struct Item: Codable, Hashable, Identifiable {
var id: UUID
var text: String
}
//MARK: - ScrollView that pushes text to the bottom of the display
struct ChatStyleScrollView<Content: View>: View {
let content: Content
init(#ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
GeometryReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
VStack {
Spacer()
content
}
.frame(minHeight: proxy.size.height)
}
}
}
}
//MARK: - A single item and its layout
struct ItemView: View {
var item: Item
var body: some View {
HStack {
Text(item.text)
.frame(height: 100)
.contextMenu {
Button(action: { }) {
Text("Reply")
}
}
Spacer()
}
}
}
//MARK: - TextField and Send button used to input new items
struct TextEntryView: View {
#Binding var items: [Item]
#Binding var text: String
#Binding var targetItem: Item?
var body: some View {
HStack {
TextField("Item", text: $text)
.frame(height: 44)
Button(action: send) { Text("Send") }
}
.padding(.horizontal)
}
func send() {
guard !text.isEmpty else { return }
let item = Item(id: UUID(), text: text)
items.append(item)
text = ""
targetItem = item
}
}
If someone is searching for a solution in UIKit: instead of the cell, you should use the contentView or a subview of the contentView as a paramterer for the UITargetedPreview. Like this:
extension CustomScreen: UITableViewDelegate {
func tableView(_ tableView: UITableView,
contextMenuConfigurationForRowAt indexPath: IndexPath,
point: CGPoint) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(identifier: indexPath as NSCopying,
previewProvider: nil) { _ in
// ...
return UIMenu(title: "", children: [/* actions */])
}
}
func tableView(
_ tableView: UITableView,
previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
getTargetedPreview(for: configuration.identifier as? IndexPath)
}
func tableView(
_ tableView: UITableView,
previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
getTargetedPreview(for: configuration.identifier as? IndexPath)
}
}
extension CustomScreen {
private func getTargetedPreview(for indexPath: IndexPath?) -> UITargetedPreview? {
guard let indexPath = indexPath,
let cell = tableView.cellForRow(at: indexPath) as? CustomTableViewCell else { return nil }
return UITargetedPreview(view: cell.contentView,
parameters: UIPreviewParameters().then { $0.backgroundColor = .clear })
}
}
If I understood it correctly, why don't you order your array in the for each loop or prior. Then you do not have to use any scaleEffect at all. Later if you get your message object, you probably have a Date assinged to it, so you can order it by the date. In your case above you could use:
ForEach(arr.reverse(), id: \.self) { item in
...
}
Which will print 12ccccc as first message at the top, and 1aaaaa as last message.

SwiftUI on tvOS: How to know which item is selected in a list

In a list, i need to know which item is selected and this item have to be clickable.
This is what i try to do:
| item1 | info of the item3 (selected) |
| item2 | |
|*item3*| |
| item4 | |
I can make it with .focusable() but it's not clickable.
Button or NavigationLink works but i can't get the current item selected.
When you use Button or NavigationLink .focusable don't hit anymore.
So my question is:
How i can get the current item selected (so i can display more infos about this item) and make it clickable to display the next view ?
Sample code 1: Focusable works but .onTap doesn't exists on tvOS
import SwiftUI
struct TestList: Identifiable {
var id: Int
var name: String
}
let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)") }
struct SwiftUIView : View {
var testList: [TestList]
var body: some View {
List {
ForEach(testList) { txt in
TestRow(row: txt)
}
}
}
}
struct TestRow: View {
var row: TestList
#State private var backgroundColor = Color.clear
var body: some View {
Text(row.name)
.focusable(true) { isFocused in
self.backgroundColor = isFocused ? Color.green : Color.blue
if isFocused {
print(self.row.name)
}
}
.background(self.backgroundColor)
}
}
Sample code 2: items are clickable via NavigationLink but there is no way to get the selected item and .focusable is not called anymore.
import SwiftUI
struct TestList: Identifiable {
var id: Int
var name: String
}
let testData = [Int](0..<50).map { TestList(id: $0, name: "Row \($0)") }
struct SwiftUIView : View {
var testList: [TestList]
var body: some View {
NavigationView {
List {
ForEach(testList) { txt in
NavigationLink(destination: Text("Destination")) {
TestRow(row: txt)
}
}
}
}
}
}
struct TestRow: View {
var row: TestList
#State private var backgroundColor = Color.clear
var body: some View {
Text(row.name)
.focusable(true) { isFocused in
self.backgroundColor = isFocused ? Color.green : Color.blue
if isFocused {
print(self.row.name)
}
}
.background(self.backgroundColor)
}
}
It seems like a major oversite to me you can't attach a click event in swiftui for tvos. I've come up with a hack that allows you to make most swiftui components selectable and clickable. Hope it helps.
First I need to make a UIView that captures the events.
class ClickableHackView: UIView {
weak var delegate: ClickableHackDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
if event?.allPresses.map({ $0.type }).contains(.select) ?? false {
delegate?.clicked()
} else {
superview?.pressesEnded(presses, with: event)
}
}
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
delegate?.focus(focused: isFocused)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFocused: Bool {
return true
}
}
The clickable delegate:
protocol ClickableHackDelegate: class {
func focus(focused: Bool)
func clicked()
}
Then make a swiftui extension for my view
struct ClickableHack: UIViewRepresentable {
#Binding var focused: Bool
let onClick: () -> Void
func makeUIView(context: UIViewRepresentableContext<ClickableHack>) -> UIView {
let clickableView = ClickableHackView()
clickableView.delegate = context.coordinator
return clickableView
}
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<ClickableHack>) {
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
class Coordinator: NSObject, ClickableHackDelegate {
private let control: ClickableHack
init(_ control: ClickableHack) {
self.control = control
super.init()
}
func focus(focused: Bool) {
control.focused = focused
}
func clicked() {
control.onClick()
}
}
}
Then I make a friendlier swiftui wrapper so I can pass in any kind of component I want to be focusable and clickable
struct Clickable<Content>: View where Content : View {
let focused: Binding<Bool>
let content: () -> Content
let onClick: () -> Void
#inlinable public init(focused: Binding<Bool>, onClick: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content) {
self.content = content
self.focused = focused
self.onClick = onClick
}
var body: some View {
ZStack {
ClickableHack(focused: focused, onClick: onClick)
content()
}
}
}
Example usage:
struct ClickableTest: View {
#State var focused1: Bool = false
#State var focused2: Bool = false
var body: some View {
HStack {
Clickable(focused: self.$focused1, onClick: {
print("clicked 1")
}) {
Text("Clickable 1")
.foregroundColor(self.focused1 ? Color.red : Color.black)
}
Clickable(focused: self.$focused2, onClick: {
print("clicked 2")
}) {
Text("Clickable 2")
.foregroundColor(self.focused2 ? Color.red : Color.black)
}
}
}
}
mark a view as focusable true (stating you want it to be able to have a focus), and implement onFocusChange to save the focus state
.focusable(true, onFocusChange: { focused in
isFocused = focused
})
you need to save the isFocused as a #State var
#State var isFocused: Bool = false
then style your View based on the isFocused value
.scaleEffect(isFocused ? 1.2 : 1.0)
here is a fully working example:
struct MyCustomFocus: View {
#State var isFocused: Bool = false
var body: some View {
Text("Select Me")
.focusable(true, onFocusChange: { focused in
isFocused = focused
})
.shadow(color: Color.black, radius: isFocused ? 10 : 5, x: 5, y: isFocused ? 20 : 5)
.scaleEffect(isFocused ? 1.2 : 1.0)
.animation(.spring().speed(2))
.padding()
}
}
struct CustomFocusTest: View {
var body: some View {
VStack
{
HStack
{
MyCustomFocus()
MyCustomFocus()
MyCustomFocus()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.yellow)
.ignoresSafeArea(.all) // frame then backround then ignore for full screen background (order matters)
.edgesIgnoringSafeArea(.all)
}
}
I haven't had much luck with custom button styles on tvOS, unfortunately.
However, to create a focusable, selectable custom view in SwiftUI on tvOS you can set the button style to plain. This allows you to keep the nice system-provided focus and selection animations, while you provide the destination and custom layout. Just add the .buttonStyle(PlainButtonStyle()) modifier to your NavigationLink:
struct VideoCard: View {
var body: some View {
NavigationLink(
destination: Text("Video player")
) {
VStack(alignment: .leading, spacing: .zero) {
Image(systemName: "film")
.frame(width: 356, height: 200)
.background(Color.white)
Text("Video Title")
.foregroundColor(.white)
.padding(10)
}
.background(Color.primary)
.frame(maxWidth: 400)
}
.buttonStyle(PlainButtonStyle())
}
}
Here's a screenshot of what it looks like in the simulator.
Clicking the button on the Siri remote, or Enter or a keyboard, should work as you'd expect.