I have a SwiftUI that shows two items in a List view. I want to be able push the desired list entry from UIKit. For this purpose, I have created an #State var which I want to set from UIKit to trigger the desired list entry via the binding on the NavigationLink's selection parameter.
public struct DateOptions: View, MiscellaneousHelper {
#State var selectedItem: AppMainFeatureData? = nil
let tabBarController: TabBarController
public var body: some View {
NavigationView {
List(AppMainFeatureData.dateItems, id: \.self) { item in
NavigationLink(destination: DateFeatureController(svc: tabBarController.svc, feature: item),
tag: item, selection: $selectedItem) {
ImageText(text: item.title, imageName: item.iconName)
}
}
.navigationBarTitle(SideBarSection.dates.title)
}
}
}
Here's my UIViewControllerRepresentable:
struct DateFeatureController: UIViewControllerRepresentable, MiscellaneousHelper {
let svc: SplitViewController
let feature: AppMainFeatureData
func makeUIViewController(context: Context) -> UINavigationController {
UINavigationController(rootViewController: feature.viewController)
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
}
}
The is how I create DateOptions in my UITabBar and how it gets added to the viewControllers:
let dateViewController = UIHostingController(rootView: DateOptions(tabBarController: self))
dateViewController.tabBarItem = tabBarItem
viewControllers?.insert(dateViewController, at: 1)
Here's how I set selectedItem from a UITabBar:
if let dateFeature = viewControllers?[selectedIndex] as? UIHostingController<DateOptions> {
dateFeature.rootView.selectedItem = .dateAddSubtract
dPrint("After \(dateFeature.rootView.selectedItem)")
}
But the print shows selectedItem as nil.
Everything works fine when the the user touches a List entry.
How can I set selectedItem from UIKit?
Related
I am using a Swiftui list to show tasks. Each task has a location. When you click on the list row I am using a NavigationLink to navigate to a details page.
I am using a UIViewControllerRepresentable that creates a map view with all the task locations annotated on the map. I had to use UIKit so that you can interact with the annotations. I have set up a delegate that fires when the user clicks the map annotation accessory.
The aim: when the delegate function is called I want to pass back the taskid which is used to navigate to the details page like when the list row is clicked on:
The way I am Navigating from the list:
ForEach(tasksToDisplayInList, id: \.id) { task in
NavigationLink(
destination: TaskDetailsView(taskId: task.id, title: task.title))
{
TaskListRow(task: task)
.padding(.trailing, 5)
.foregroundColor(.textDefault)
}
}
The Task List Map UIViewControllerRepresentable that passes back the pressed task id:
struct TaskListMap: UIViewControllerRepresentable {
typealias UIViewControllerType = TaskListMapController
#Binding var annotations: [TaskAnnotation]
class Coordinator: TaskListMapDelegate {
func didPressMoreDetail(_ mapController: TaskListMapController, for annotation: TaskAnnotation) {
// TaskAnnotation contains the taskId
// Navigate here somehow?
}
var parent: TaskListMap
init(_ parent: TaskListMap) {
self.parent = parent
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> TaskListMapController {
let mapController = TaskListMapController(annotations: annotations)
mapController.delegate = context.coordinator
return mapController
}
func updateUIViewController(_ uiViewController: TaskListMapController, context: Context) {
if annotations.count != uiViewController.mapView.annotations.count {
uiViewController.mapView.removeAnnotations(uiViewController.mapView.annotations)
uiViewController.mapView.addAnnotations(annotations)
}
}
}
As a workaround, this seems to work fine for my purpose:
// # TaskListMapView.swift (inside the coordinator)
class Coordinator: TaskListMapDelegate {
var parent: TaskListMap
init(_ parent: TaskListMap) {
self.parent = parent
}
func didPressMoreDetail(_ mapController: TaskListMapController, for annotation: TaskAnnotation) {
// Push the navigation with a hosting controller
let detailsView = UIHostingController(rootView: TaskDetailsView(taskId: annotation.taskId, title: annotation.title ?? ""))
mapController.navigationController?.pushViewController(detailsView, animated: true)
}
}
I have a swiftUI view with navigation links that when i click will navigate to another view . The issue is the second view navigationBa button title still has the title of the previous view instead of a logical back title . How i can have the title as Back with changing the title as "Back" in the first view .
First view navigationBar code: The second view just shows the news website in a WebView.
.navigationBarTitle("Breaking News")
The way i tried is changing the title to this:
.navigationBarTitle("Back")
This will work but the title of the first view changes to "Back" Instead of "Breaking News"
Is there any way i can fix this
An alternative approach is to hide the back button and create your own back button like this:
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination : SomeView()) {
Text("Open")
}
.navigationBarTitle("Breaking News")
}
}
}
// Use navigationBarItems for creating your own bar item.
struct SomeView : View {
#Environment(\.presentationMode) var mode
var body : some View {
Text("Hello, World!")
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading:
Button(action : {
self.mode.wrappedValue.dismiss()
}){
Text("\(Image(systemName: "chevron.left"))Back")
})
}
}
The accepted answer looks glitchy, it removes a lot of standard behaviour and animations, including long press gesture.
Consider using custom backBarButtonTitle modifier:
struct FirstView: View {
var body: some View {
Text("First view")
.backBarButtonTitle("Back")
}
}
It sets the backButtonTitle property to the topmost UINavigationItem in stack. Be sure to use this modifier on the view, whose title you want to change, see documentation.
Here is the implementation of the backBarButtonTitle:
import SwiftUI
import UIKit
extension View {
func backBarButtonTitle(_ title: String) -> some View {
modifier(BackButtonModifier(title: title))
}
}
// MARK: - BackButtonModifier
struct BackButtonModifier: ViewModifier {
let title: String
func body(content: Content) -> some View {
content.background(BackButtonTitleView(title: title))
}
}
// MARK: - BackButtonTitleView
private struct BackButtonTitleView: UIViewRepresentable {
let title: String
func makeUIView(context _: Context) -> BackButtonTitleUIView {
BackButtonTitleUIView(title: title)
}
func updateUIView(_: BackButtonTitleUIView, context _: Context) {}
}
// MARK: - BackButtonTitleUIView
private final class BackButtonTitleUIView: UIView {
// MARK: Lifecycle
init(title: String) {
self.title = title
super.init(frame: .zero)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
override func layoutSubviews() {
super.layoutSubviews()
if didConfigureTitle {
return
}
let topNavigationItem = searchNavigationController(currentResponder: self)?
.topViewController?
.navigationItem
if let topNavigationItem {
topNavigationItem.backButtonTitle = title
didConfigureTitle = true
}
}
// MARK: Private
private let title: String
private var didConfigureTitle = false
private func searchNavigationController(currentResponder: UIResponder) -> UINavigationController? {
if let navigationController = currentResponder as? UINavigationController {
return navigationController
} else if let nextResponder = currentResponder.next {
return searchNavigationController(currentResponder: nextResponder)
} else {
return nil
}
}
}
I am learning Swift & SwiftUI as a hobby with no UIKit background, so I am not sure if this is currently possible. I would really like to use UIKit's context menus with SwiftUI (e.g. to implement submenus, action attributes and maybe custom preview providers).
My original idea was to create a LegacyContextMenuView with UIViewControllerRepresentable. Then I'd use a UIHostingController to add a SwiftUI View as a child of a UIViewController ContainerViewController to which I'd add a UIContextMenuInteraction.
My current solution kinda works but when the context menu is activated the preview frame of the 'ContainerViewController' view does not fit the size of UIHostingController view. I am not familiar with UIKit's layout system so I'd like to know:
Is it possible to add such constrains while the preview is activated?
Is it possible to preserve the clipShape of the underlying SwiftUI view inside the preview provider?
The code:
// MARK: - Describes a UIKit Context Menu
struct LegacyContextMenu {
let title: String
let actions: [UIAction]
var actionProvider: UIContextMenuActionProvider {
{ _ in
UIMenu(title: title, children: actions)
}
}
init(actions: [UIAction], title: String = "") {
self.actions = actions
self.title = title
}
}
// MARK: - A View that brings UIKit context menus into the SwiftUI world
struct LegacyContextMenuView<Content: View>: UIViewControllerRepresentable {
let content: Content
let menu: LegacyContextMenu
func makeUIViewController(context: Context) -> UIViewController {
let controller = ContainerViewController(rootView: content)
let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
controller.view.addInteraction(menuInteraction)
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
let parent: LegacyContextMenuView
init(parent: LegacyContextMenuView) { self.parent = parent }
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration?
{
// previewProvider nil = using the default UIViewController: ContainerViewController
UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: parent.menu.actionProvider)
}
}
class ContainerViewController: UIViewController {
let hostingController: UIHostingController<Content>
init(rootView: Content) {
self.hostingController = UIHostingController(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
setupHostingController()
setupContraints()
// Additional setup required?
}
func setupHostingController() {
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
}
// Not familiar with UIKit's layout system so unsure if this is the best approach
func setupContraints() {
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addConstraints([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
hostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
hostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor)
])
}
}
}
// MARK: - Simulate SwiftUI syntax
extension View {
func contextMenu(_ legacyContextMenu: LegacyContextMenu) -> some View {
self.modifier(LegacyContextViewModifier(menu: legacyContextMenu))
}
}
struct LegacyContextViewModifier: ViewModifier {
let menu: LegacyContextMenu
func body(content: Content) -> some View {
LegacyContextMenuView(content: content, menu: menu)
}
}
Then to test, I use this:
// MARK - A sample view with custom content shape and a dynamic frame
struct SampleView: View {
#State private var isLarge = false
let viewClipShape = RoundedRectangle(cornerRadius: 50.0)
var body: some View {
ZStack {
Color.blue
Text(isLarge ? "Large" : "Small")
.foregroundColor(.white)
.font(.largeTitle)
}
.onTapGesture { isLarge.toggle() }
.clipShape(viewClipShape)
.contentShape(viewClipShape)
.frame(height: isLarge ? 250 : 150)
.animation(.easeInOut, value: isLarge)
}
}
struct ContentView: View {
var body: some View {
SampleView()
.contextMenu(LegacyContextMenu(actions: [sampleAction], title: "My Menu"))
.padding(.horizontal)
}
let sampleAction = UIAction(
title: "Remove",
image: UIImage(systemName: "trash.fill"),
identifier: nil,
attributes: UIMenuElement.Attributes.destructive,
handler: { _ in print("Pressed 'Remove'") })
}
While long pressing, the context menu scaling animation respects the content shape of SampleView for both small and large sizes, but the preview pops out like this:
you must set preferredContentSize of ViewController to fit content size u want
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.
I have a UIHostingController contained in a UIViewControllerRepresentable that holds a reference to a binding. When the binding changes, updateUIViewController is called, but the underlying view is not automatically re-rendered. How can I signal to the embedded UIHostingController that it needs to update its content?
Following is a simplified version of the scenario. Note that when the stepper changes, the first Text automatically updates, but the text contained within the PassthroughView UIViewControllerRepresentable does not automatically see its content get re-rendered.
import SwiftUI
struct ContentView: View {
#State var number = 99
var body: some View {
VStack {
Stepper("Stepper", value: $number)
Text("Direct Value: \(number)")
PassthroughView {
Text("Passthrough Value: \(number)")
}
Spacer()
}.font(.headline)
}
}
struct PassthroughView<V: View> : UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<V>
let content: V
init(#ViewBuilder _ content: () -> V) {
self.content = content()
}
func makeUIViewController(context: UIViewControllerRepresentableContext<PassthroughView<V>>) -> UIViewControllerType {
UIViewControllerType(rootView: content)
}
func updateUIViewController(_ controller: UIViewControllerType, context: UIViewControllerRepresentableContext<PassthroughView<V>>) {
// this is called when the binding changes;
// how to tell the UIHostingController to re-render?
}
}
The following code will work as desired:
I am not sure if it is good practice since I am not very familiar with UIKit.
struct PassthroughView<V: View> : UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<V>
let content: V
init(#ViewBuilder _ content: () -> V) {
self.content = content()
}
func makeUIViewController(context: UIViewControllerRepresentableContext<PassthroughView<V>>) -> UIViewControllerType {
UIViewControllerType(rootView: content)
}
func updateUIViewController(_ controller: UIViewControllerType, context: UIViewControllerRepresentableContext<PassthroughView<V>>) {
// this is called when the binding changes;
// how to tell the UIHostingController to re-render?
controller.rootView = content
}
}
struct ContentView: View {
#State var number = 99
var body: some View {
VStack {
Stepper("Stepper", value: $number)
Text("Direct Value: \(number)")
PassthroughView {
Text("Passthrough Value: \(number)")
}
Spacer()
}.font(.headline)
}
}
I hope this helps!