UIViewControllerRepresentable not correctly taking up space - ios

I am trying to use a custom UIViewController in a SwiftUI view. I set up a UIViewControllerRepresentable class which creates the UIViewController in the makeUIViewController method. This creates the UIViewController and displays the button, however, the UIViewControllerRepresentable does not take up any space.
I tried using a UIImagePickerController instead of my custom controller, and that sizes correctly. The only way I got my controller to take up space was by setting a fixed frame on the UIViewControllerRepresentable in my SwiftUI view, which I absolutely don't want to do.
Note: I do need to use a UIViewController because I am trying to implement a UIMenuController in SwiftUI. I got all of it to work besides this problem I am having with it not sizing correctly.
Here is my code:
struct ViewControllerRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> MenuViewController {
let controller = MenuViewController()
return controller
}
func updateUIViewController(_ uiViewController: MenuViewController, context: Context) {
}
}
class MenuViewController: UIViewController {
override func viewDidLoad() {
let button = UIButton()
button.setTitle("Test button", for: .normal)
button.setTitleColor(.red, for: .normal)
self.view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
button.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
button.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
button.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
}
}
My SwiftUI view:
struct ClientView: View {
var body: some View {
VStack(spacing: 0) {
EntityViewItem(copyValue: "copy value", label: {
Text("Name")
}, content: {
Text("Random name")
})
.border(Color.green)
ViewControllerRepresentable()
.border(Color.red)
EntityViewItem(copyValue: "copy value", label: {
Text("Route")
}, content: {
HStack(alignment: .center) {
Text("Random route name")
}
})
.border(Color.blue)
}
}
}
Screenshot:
I do not have much experience with UIKit - my only experience is writing UIKit views to use in SwiftUI. The problem could very possibly be related to my lack of UIKit knowledge.
Thanks in advance!
Edit:
Here is the code for EntityViewItem. I will also provide the container view that ClientView is in - EntityView.
I also cleaned up the rest of the code and replaced references to Entity with hardcoded values.
struct EntityViewItem<Label: View, Content: View>: View {
var copyValue: String
var label: Label
var content: Content
var action: (() -> Void)?
init(copyValue: String, #ViewBuilder label: () -> Label, #ViewBuilder content: () -> Content, action: (() -> Void)? = nil) {
self.copyValue = copyValue
self.label = label()
self.content = content()
self.action = action
}
var body: some View {
VStack(alignment: .leading, spacing: 2) {
label
.opacity(0.6)
content
.onTapGesture {
guard let unwrappedAction = action else {
return
}
unwrappedAction()
}
.contextMenu {
Button(action: {
UIPasteboard.general.string = copyValue
}) {
Text("Copy to clipboard")
Image(systemName: "doc.on.doc")
}
}
}
.padding([.top, .leading, .trailing])
.frame(maxWidth: .infinity, alignment: .leading)
}
}
The container of ClientView:
struct EntityView: View {
let headerHeight: CGFloat = 56
var body: some View {
ZStack {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
Color.clear.frame(
height: headerHeight
)
ClientView()
}
}
VStack(spacing: 0) {
HStack {
Button(action: {
}, label: {
Text("Back")
})
Spacer()
Text("An entity name")
.lineLimit(1)
.minimumScaleFactor(0.5)
Spacer()
Color.clear
.frame(width: 24, height: 0)
}
.frame(height: headerHeight)
.padding(.leading)
.padding(.trailing)
.background(
Color.white
.ignoresSafeArea()
.opacity(0.95)
)
Spacer()
}
}
}
}

If anyone else is trying to find an easier solution, that takes any view controller and resizes to fit its content:
struct ViewControllerContainer: UIViewControllerRepresentable {
let content: UIViewController
init(_ content: UIViewController) {
self.content = content
}
func makeUIViewController(context: Context) -> UIViewController {
let size = content.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
content.preferredContentSize = size
return content
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
And then, when you use it in SwiftUI, make sure to call .fixedSize():
struct MainView: View {
var body: some View {
VStack(spacing: 0) {
ViewControllerContainer(MenuViewController())
.fixedSize()
}
}
}

Thanks so much to #udbhateja and #jnpdx for the help. That makes a lot of sense why the UIViewControllerRepresentable compresses its frame when inside a ScrollView. I did end up figuring out a solution to my problem which involved setting a fixed height on the UIViewControllerRepresentable. Basically, I used a PreferenceKey to find the height of the SwiftUI view, and set the frame of the UIViewControllerRepresentable to match it.
In case anyone has this same problem, here is my code:
struct EntityViewItem<Label: View, Content: View>: View {
var copyValue: String
var label: Label
var content: Content
var action: (() -> Void)?
#State var height: CGFloat = 0
init(copyValue: String, #ViewBuilder label: () -> Label, #ViewBuilder content: () -> Content, action: (() -> Void)? = nil) {
self.copyValue = copyValue
self.label = label()
self.content = content()
self.action = action
}
var body: some View {
ViewControllerRepresentable(copyValue: copyValue) {
SizingView(height: $height) { // This calculates the height of the SwiftUI view and sets the binding
VStack(alignment: .leading, spacing: 2) {
// Content
}
.padding([.leading, .trailing])
.padding(.top, 10)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(height: height) // Here I set the height to the value returned from the SizingView
}
}
And the code for SizingView:
struct SizingView<T: View>: View {
let view: T
#Binding var height: CGFloat
init(height: Binding<CGFloat>, #ViewBuilder view: () -> T) {
self.view = view()
self._height = height
}
var body: some View {
view.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) { preferences in
height = preferences.height
}
}
func size(with view: T, geometry: GeometryProxy) -> T {
height = geometry.size.height
return view
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
With this finished, my UIMenuController is fully functional. It was a lot of code (if this functionality existed in SwiftUI, I probably would have had to write like 5 lines of code), but it works great. If anyone would like the code, please comment and I will share.
Here is an image of the final product:

As #jnpdx mentioned, you need to provide explicit size via frame for the representable to be visible as it's nested in VStack with other View.
If you have a specific reason to use UIViewController, then do provide explicit frame or else create a SwiftUI View.
struct ClientView: View {
var body: some View {
VStack(spacing: 0) {
EntityViewItem(copyValue: "copy value", label: {
Text("Name")
}, content: {
Text("Random name")
})
.border(Color.green)
ViewControllerRepresentable()
.border(Color.red)
.frame(height: 100.0)
EntityViewItem(copyValue: "copy value", label: {
Text("Route")
}, content: {
HStack(alignment: .center) {
Text("Random route name")
}
})
.border(Color.blue)
}
}
}

For anyone looking for the simplest possible solution, it's a couple of lines in #Edudjr's answer:
let size = content.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
content.preferredContentSize = size
Just add that inside your makeUIViewController!

Related

Updating tapGesture area(frame) after device is rotated SwiftUI

I have an issue with updating the area(frame) of .onTapGesture after a device is rotated. Basically, even after changing #State var orientation the area where .onTapGesture works remain the same as on the previous orientation.
Would appreciate having any advice on how to reset that tap gesture to the new area after rotation.
Thanks in advance!
struct ContentView: View {
var viewModel = SettingsSideMenuViewModel()
var body: some View {
VStack {
SideMenu(viewModel: viewModel)
Button("Present menu") {
viewModel.isShown.toggle()
}
Spacer()
}
.padding()
}
}
final class SettingsSideMenuViewModel: ObservableObject {
#Published var isShown = false
func dismissHostingController() {
guard !isShown else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
debugPrint("viewShoudBeDismissedHere")
}
}
}
struct SideMenu: View {
#ObservedObject var viewModel: SettingsSideMenuViewModel
#State private var orientation = UIDeviceOrientation.unknown
var sideBarWidth = UIScreen.main.bounds.size.width * 0.7
var body: some View {
GeometryReader { proxy in
ZStack {
GeometryReader { _ in
EmptyView()
}
.background(Color.black.opacity(0.6))
.opacity(viewModel.isShown ? 1 : 0)
.animation(.easeInOut.delay(0.2), value: viewModel.isShown)
.onTapGesture {
viewModel.isShown.toggle()
viewModel.dismissHostingController()
}
content
}
.edgesIgnoringSafeArea(.all)
.frame(width: proxy.size.width,
height: proxy.size.height)
.onRotate { newOrientation in
orientation = newOrientation
}
}
}
var content: some View {
HStack(alignment: .top) {
ZStack(alignment: .top) {
Color.white
Text("SOME VIEW HERE")
VStack(alignment: .leading, spacing: 20) {
Text("SOME VIEW HERE")
Divider()
Text("SOME VIEW HERE")
Divider()
Text("SOME VIEW HERE")
}
.padding(.top, 80)
.padding(.horizontal, 40)
}
.frame(width: sideBarWidth)
.offset(x: viewModel.isShown ? 0 : -sideBarWidth)
.animation(.default, value: viewModel.isShown)
Spacer()
}
}
}
struct DeviceRotationViewModifier: ViewModifier {
let action: (UIDeviceOrientation) -> Void
func body(content: Content) -> some View {
content
.onAppear()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
action(UIDevice.current.orientation)
}
}
}
extension View {
func onRotate(perform action: #escaping (UIDeviceOrientation) -> Void) -> some View {
self.modifier(DeviceRotationViewModifier(action: action))
}
}
struct SideMenu_Previews: PreviewProvider {
static var viewModel = SettingsSideMenuViewModel()
static var previews: some View {
SideMenu(viewModel: viewModel)
}
}
In this example is just slideoutMenu with a blurred area. By opening that menu in portrait and taping on the blurred area this menu should close. The issue is when the menu is opened in portrait and then rotated to landscape - the tapGesture area stays the same as it was in portrait, hence if tapped in the landscape - nothing happens. This works in the same direction too. Thus the question is how to reset the tapGesture area on rotation?
This view is presented in UIHostingController. slideOutView?.modalPresentationStyle = .custom the issue is there. But if slideOutView?.modalPresentationStyle = .fullScreen (or whatever) - everything works okay.

SwiftUI view over all the views including sheet view

I need to show a view above all views based upon certain conditions, no matter what the top view is. I am trying the following code:
struct TestView<Presenting>: View where Presenting: View {
/// The binding that decides the appropriate drawing in the body.
#Binding var isShowing: Bool
/// The view that will be "presenting" this notification
let presenting: () -> Presenting
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
self.presenting()
HStack(alignment: .center) {
Text("Test")
}
.frame(width: geometry.size.width - 44,
height: 58)
.background(Color.gray.opacity(0.7))
.foregroundColor(Color.white)
.cornerRadius(20)
.transition(.slide)
.opacity(self.isShowing ? 1 : 0)
}
}
}
}
extension View {
func showTopView(isShowing: Binding<Bool>) -> some View {
TestView(isShowing: isShowing,
presenting: { self })
}
}
struct ContentView: View {
#State var showTopView = false
NavigationView {
ZStack {
content
}
}
.showTopView(isShowing: $showTopView)
}
Now this is working fine in case of the views being pushed. But I am not able to show this TopView above the presented view.
Any help is appreciated!
Here is a way for your goal, you do not need Binding, just use let value.
struct ContentView: View {
#State private var isShowing: Bool = Bool()
var body: some View {
CustomView(isShowing: isShowing, content: { yourContent }, isShowingContent: { isShowingContent })
}
var yourContent: some View {
NavigationView {
VStack(spacing: 20.0) {
Text("Hello, World!")
Button("Show isShowing Content") { isShowing = true }
}
.navigationTitle("My App")
}
}
var isShowingContent: some View {
ZStack {
Color.black.opacity(0.5).ignoresSafeArea()
VStack {
Spacer()
Button("Close isShowing Content") { isShowing = false }
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue.cornerRadius(10.0))
.padding()
}
}
}
}
struct CustomView<Content: View, IsShowingContent: View>: View {
let isShowing: Bool
#ViewBuilder let content: () -> Content
#ViewBuilder let isShowingContent: () -> IsShowingContent
var body: some View {
Group {
if isShowing { ZStack { content().blur(radius: isShowing ? 5.0 : 0.0); isShowingContent() } }
else { content() }
}
.animation(.default, value: isShowing)
}
}

SwiftUI NavigationLink for iOS 14.5 not working

I had the following code in Xcode 12.4 that worked perfectly
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: rows, spacing: 0) {
HStack {
if (type == "Quiz") {
NavigationLink(destination: Quiz(id: quiz.id)) {
VStack(alignment: .leading) {
Text("Quiz")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.green)
.cornerRadius(12)
.shadow(color: .green, radius: 3, x: 0.0, y: 0.0)
}
} else {
NavigationLink(destination: Survey(id: survey.id)) {
VStack(alignment: .leading) {
Text("Survey")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.green)
.cornerRadius(12)
.shadow(color: .green, radius: 3, x: 0.0, y: 0.0)
}
} // End If
if (type == "Quiz") {
NavigationLink(destination: QuizResults(id: quiz.id)) {
VStack(alignment: .leading) {
Text("Quiz Results")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.blue)
.cornerRadius(12)
.shadow(color: .blue, radius: 3, x: 0.0, y: 0.0)
}
} else {
NavigationLink(destination: SurveyResults(id: survey.id)) {
VStack(alignment: .leading) {
Text("Survey Results")
.font(.headline)
.foregroundColor(.white)
.padding(.top, 8)
.padding(.leading)
}
.background(Color.blue)
.cornerRadius(12)
.shadow(color: .blue, radius: 3, x: 0.0, y: 0.0)
}
}
}
.padding([.leading, .trailing], 25)
}
.frame(height: 100)
I just updated Xcode to 12.5 and the above does not work any more.
It was working fine in 12.4!?
Now when I click the 'Quiz' element, it starts the transition to the Quiz View which is displays it but immediately closes the view and I'm back in the Detail View!?
Can someone see what I am doing wrong, and why now based on the update to 12.5 this stopped working?
UPDATE
I refined the code to the minimal possible reproducible form. What seems to be happening is that I have two or more NavigationLinks sets.
the first is the set to navigate the user to either the Quiz or Survey which the if statement addresses the user to the correct view to fill in.
Where the issue is in 12.5 is that the second set where the user can click to go see the overall results of the Quiz or Survey does not work when it's directly after the first navigation.
Like I said before hand it worked perfectly in 12.4 but seems like 12.5 does not agree with it. Can someone offer a better way for the user to click an element to either go fill in a quiz or survey or go see the results of a quiz or survey?
I got exactly the same problem, everything works fine with Xcode 12.4.
https://developer.apple.com/forums/thread/677333
I try to following this thread, it might work but on some case, I still have this bug.
NavigationLink(destination: EmptyView()) {
EmptyView()
}
Apparently, you can put this 3 lines of code close to your NavigationLink...
If someone got a better answer I will really appreciate it !
What a horrible bug! From my testing and some googling it happens when there are exactly 2 navigation links in a view. The code in the question has 4 but because of the if else statements there are effectively only 2 at a time.
I often don't know how many nav links I will have as it depends on what data the user has added/how many search hits there are etc. To be safe I've made a tripleEmptyNavigationLink modifier which I've stuck at the end of all my views. It's solving the popping behaviour but I'm still getting the 'Unable to present' warnings. Would love to know if anyone has anything better than this!
import SwiftUI
struct TripleEmptyNavigationLink: View {
var body: some View {
VStack {
NavigationLink(destination: EmptyView()) {EmptyView()}
NavigationLink(destination: EmptyView()) {EmptyView()}
NavigationLink(destination: EmptyView()) {EmptyView()}
}
}
}
struct TripleEmptyNavigationLinkBackground: ViewModifier {
func body(content: Content) -> some View {
content
.background(TripleEmptyNavigationLink())
}
}
extension View {
func tripleEmptyNavigationLink()-> some View {
self.modifier(TripleEmptyNavigationLinkBackground())
}
}
usage:
MyView()
.tripleEmptyNavigationLink()
In Xcode13 beta still has this issue.
So far solution:
1、Wrap NavigationLink with List or Form:
List {
NavigationLink(destination: Text("1")) {
Text("1")
}
NavigationLink(destination: Text("2")) {
Text("2")
}
NavigationLink(destination: Text("3")) {
Text("3")
}
}
2、Or Use one NavigationLink, and create destination view from func:
struct TaskIndexPage: View {
func buildView() -> some View {
// return you destination
switch self.option {
case 1:
return Text("\(option)")
default:
return Text("\(option)")
}
}
#State private var showDetail: Bool = false
#State private var option: Int = 0
var body: some View {
VStack {
Button {
showDetail = true
option = 1
} label: { Text("button 1") }
Button {
showDetail = true
option = 2
} label: { Text("button 2") }
Button {
showDetail = true
option = 3
} label: { Text("button 3") }
}
// handle navigating
NavigationLink(destination: self.buildView(), isActive: $showDetail) {}.opacity(0)
}
}
Adding a NavigationLink with an empty view didn't work for me. I solved my issue removing all NavigationLinks from the ForEach and using a single one to control the navigation to the detail view, a tap gesture and 2 state variables to keep track on what is being tapped on.
The example broken code and fix can be found at Paul Hudson's site.
https://www.hackingwithswift.com/forums/swiftui/unable-to-present-please-file-a-bug/7901/8237
Below is the complete working version
import SwiftUI
struct NavigationViewOptions {
enum OptionType { case main, optional }
typealias Option = (id: UUID, value: String, type: Self.OptionType)
static var options: [Option] = [
(UUID(), "Option 1", .main),
(UUID(), "Option 2", .optional),
(UUID(), "Option 3", .main),
(UUID(), "Option 4", .main),
(UUID(), "Option 5", .optional),
]
static func buildView(for option: Option) -> some View {
switch option.type {
case .main:
return Text("Main Option selected\n\(option.value)").font(.title).fontWeight(.bold)
case .optional:
return Text("Optional Option selected\n\(option.value)").font(.title3).italic().fontWeight(.medium)
}
}
}
struct NavigationViewWorking: View {
// State variables to leep track of what option has been tapped on and when to navigate to new view
#State private var selectedOption: NavigationViewOptions.Option = (id:UUID(),"",.main)
#State private var showDetail: Bool = false
var body: some View {
NavigationView {
ScrollView{
VStack (alignment:.leading) {
Text("NAVIGATION FIX FOR:\nUnable to present. Please file a bug.")
.padding(.bottom, 40)
ForEach(NavigationViewOptions.options, id: \.id) { option in
Text(option.value)
.font(.title)
.padding(.vertical, 10)
.foregroundColor(.accentColor) // same color as navigationLink
// handle tap on option
.onTapGesture {
selectedOption = option
showDetail = true
}
}
Spacer()
NavigationLink("", destination: NavigationViewOptions.buildView(for: selectedOption), isActive: $showDetail)
.opacity(0)
}
.navigationTitle("Options")
}
// INITIAL DETAIL VIEW
Text("Select option from the left")
}
}
}
For me the correct answer didn't work.
It showed Unable to present-message and then required view was pushed and poped out back quickly.
While playing around I found a working solution. I keep NotificationLink's without label set as a plain List items.
NavigationView {
ZStack {
List {
NavigationLink(isActive: $isFirstViewPresented,
destination: firstView,
label: EmptyView.init)
NavigationLink(isActive: $isSecondViewPresented,
destination: secondView,
label: EmptyView.init)
}
.listStyle(.plain)
//...
Button("Show first view") { isFirstViewPresented.toggle() }
Button("Show second view") { isSecondViewPresented.toggle() }
}
}
Don't forget to wrap active-properties with #State.
It also has some benefits as for me (all the navigation links are placed at the top of the view-getter and I don't need to look for it through all the code.
I could never find a reliable solution to this horrible bug. So I decided to create a custom NavigationLink, https://gist.github.com/Arutyun2312/a0dab7eecaa84bde99c435fecae76274. This works way better than expected, because all swiftui related functions continue working as usual. Seems like the bug is specifically with NavigationLink.
struct NavigationLink: View {
fileprivate init<T: View>(body: T) {
self.body = .init(body)
}
let body: AnyView
}
private struct NavigationLinkImpl<Destination: View, Label: View>: View {
let destination: () -> Destination?
#State var isActive = false
#ViewBuilder let label: () -> Label
var body: some View {
NavigationLinkImpl1(destination: destination, isActive: $isActive, label: label)
}
}
private struct NavigationLinkImpl1<Destination: View, Label: View>: View {
let destination: () -> Destination
#Binding var isActive: Bool
#ViewBuilder let label: () -> Label
#State var model = Model()
var body: some View {
Button(action: action, label: label)
.introspectNavigationController(customize: handle)
.id(isActive)
}
func handle(nav: UINavigationController) {
if isActive {
if model.destination == nil {
let dest = UIHostingController<Destination>(rootView: destination())
nav.pushViewController(dest, animated: true)
model.destination = dest
}
} else {
if let dest = model.destination {
if let i = nav.viewControllers.lastIndex(of: dest) {
nav.setViewControllers(.init(nav.viewControllers.prefix(i + 1)), animated: true)
}
model.destination = nil
}
}
if isActive != model.contains(nav: nav) { // detect pop
isActive = model.contains(nav: nav)
}
}
final class Model {
var destination: UIHostingController<Destination>?
func contains(nav: UINavigationController) -> Bool { destination.map { nav.viewControllers.contains($0) } ?? false }
}
func action() { isActive = true }
}
extension NavigationLink {
init<Destination: View, Label: View>(destination: #autoclosure #escaping () -> Destination, #ViewBuilder label: #escaping () -> Label) {
self.init(body: NavigationLinkImpl(destination: destination, label: label))
}
init<Destination: View, Label: View>(destination: #autoclosure #escaping () -> Destination, isActive: Binding<Bool>, #ViewBuilder label: #escaping () -> Label) {
self.init(body: NavigationLinkImpl1(destination: destination, isActive: isActive, label: label))
}
init<Destination: View>(_ text: String, destination: #autoclosure #escaping () -> Destination, isActive: Binding<Bool>) {
self.init(destination: destination(), isActive: isActive) { Text(text) }
}
init<Destination: View>(_ text: String, destination: #autoclosure #escaping () -> Destination) {
self.init(destination: destination()) { Text(text) }
}
}
Put this in a file, and your existing NavigationLinks will work just fine. Tested in ios 14 and 15
Like anybody else on iOS 14.5.1 my application is hit by this awful bug. I have more than 3 NavigationLinks in the page, and I was not lucky to modify the numbers of the NavigationLinks (by adding a dummy NavigationLink) to get the correct behaviour.
A workaround that is Okay for me is to add a NavigationLink conditionally into the view.
Instead of:
var body: some View {
NavigationLink(destination: AnotherView(), isActive: $someCondition) { EmptyView() }
}
I have this:
var body: some View {
if someCondition {
NavigationLink(destination: AnotherView(), isActive: $someCondition) { EmptyView() }
}
}
The behaviour is not exactly the same, as you lose some navigation animation candy, but at least you have a working application again with relatively easy to understand fix.
You can also short-circuit it to 14.5 only, and normal behaviour elsewhere:
/// Assumes this gets fixed by Apple until 14.6 is out
var onIOS14_5: Bool {
let systemVersion = UIDevice.current.systemVersion
return systemVersion.starts(with: "14.5")
}
var body: some View {
if !onIOS14_5 || someCondition {
NavigationLink(destination: AnotherView(), isActive: $someCondition) { EmptyView() }
}
}
Perhaps this helps someone and lets all hope Apple will fix this embarrasing bug. Now I want my half day back.
In my case, the NavigationLink didn't work because of an .onTapGesture I added to dismiss the keyboard.
I got exactly the same problem.
my code:
class NavigationManager: ObservableObject {
static let shared: NavigationManager = {
return NavigationManager()
}()
#Published var showingMain: Bool
#Published var showingSub: Bool
#Published var content: AnyView
init() {
showingMain = false
showingSub = false
content = AnyView(EmptyView())
}
func forward<T:View>(content: #escaping () -> T ) {
showView()
self.content = AnyView(content())
}
private func showView() {
if !showingMain,!showingSub {
showingMain = true
} else if showingMain,!showingSub {
showingSub = true
} else if !showingMain,showingSub {
showingMain = true
}
}
}
struct NavigationLinkGroup: View {
#EnvironmentObject var navigationManager: NavigationManager
var body: some View {
Group {
NavigationLink(destination: navigationManager.content, isActive: $navigationManager.showingMain) {EmptyView()}
NavigationLink(destination: navigationManager.content, isActive: $navigationManager.showingSub) {EmptyView()}
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLinkGroup()
}
}
}
https://github.com/Ftrybe/CustomBackButtonOfSwiftUIApp/tree/master/CustomBackButtonOfSwiftUI
It seems that if there's more than one NavigationLink in NavigationView, this bug will be filed.
Here's my solution.
import SwiftUI
enum MyLink {
case myView1
case myView2
}
struct MyView1: View {
var body: some View {
Text("MyView1")
}
}
struct MyView2: View {
var body: some View {
Text("MyView2")
}
}
struct ExampleView: View {
#State var currentLink: MyLink = .myView1
#State var isLinkViewShow: Bool = false
func getLinkView(_ myLink: MyLink) -> some View {
if myLink == .myView1 {
return AnyView(MyView1())
} else {
return AnyView(MyView2())
}
}
var body: some View {
NavigationView {
VStack {
NavigationLink("",
destination: getLinkView(currentLink),
isActive: $isLinkViewShow)
// Press to navigate to MyView1
Button(action: {
currentLink = .myView1
isLinkViewShow = true
}) {
Text("To MyView1")
}
// Press to navigate to MyView2
Button(action: {
currentLink = .myView2
isLinkViewShow = true
}) {
Text("To MyView2")
}
}
}
}
}
Adding a delay gets auto-navigation working again.
NavigationLink(destination: PopupView(),isActive: $showView){}
&
.onAppear {
if (test()){
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {showView = true}
}
}

Using SwiftUI's matchedGeometryEffect in complex UI

In my app, I want to create a 'hero' animation between a card and a full screen overlay, which matchedGeometryEffect seems suited for. However, no matter what I try, I can't get the animation to work as expected and it doesn't look at all like the usual matchedGeometryEffect animations. Here's what it looks like so far. This is what I currently have: (apologies for the tons of code, but it's necessary since for a trivially simple view, it works fine)
Something.swift
struct Something: Identifiable {
let id = UUID()
let image: Image
}
ContentView.swift
struct ContentView: View {
#Namespace var namespace
let items: [Something] = [
Image("a"), Image("b")
].map { Something(image: $0 )}
#State var selectedItem: Something?
var body: some View {
ZStack {
VStack {
ScrollView {
VStack(alignment: .leading) {
ForEach(items) { item in
CardView(
image: item.image,
namespace: namespace,
isSource: self.selectedItem == nil,
id: item.id
)
.background(Color.white)
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.zIndex(1)
.onTapGesture {
withAnimation(.spring()) {
self.selectedItem = item
}
}
}
}
}
}
.overlay(EmptyView())
if let item = selectedItem {
EventView(
image: item.image
) {
self.selectedItem = nil
}
.matchedGeometryEffect(id: item.id, in: namespace, isSource: false)
.zIndex(2)
}
}
.animation(.spring())
.transition(.scale)
}
}
CardView.swift
struct CardView: View {
let image: Image
let namespace: Namespace.ID
let isSource: Bool
let id: UUID
var body: some View {
VStack(alignment: .leading) {
ZStack(alignment: .bottomTrailing) {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 225)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.matchedGeometryEffect(id: id, in: namespace, isSource: isSource)
}
}
}
}
EventView.swift
struct EventView: View {
let image: Image
let onDismiss: () -> Void
var body: some View {
image
.resizable()
.aspectRatio(contentMode: .fill)
.edgesIgnoringSafeArea(.all)
.onTapGesture(perform: onDismiss)
}
}
Any suggestions on what to add or change to get it to work properly would be super appreciated, thanks!!
What I did for the same requirement was to add properties: .position to the matchedGeometryEffect. Then you need to specify "how" you transition from one view (say, a thumbnail card view) to another (say, a full screen card view). This is accomplished through custom transitions, such as this:
extension AnyTransition
{
// This transition will pass a value (0.0 - 1.0), indicating how much of the
// transition has passed. To communicate with the view, it will
// use the custom environment key .modalTransitionPercent
// it will also make sure the transitioning view is not faded in or out and it
// stays visible at all times.
static var modal: AnyTransition
{
AnyTransition.modifier(active: ThumbnailExpandedModifier(pct: 0), identity: ThumbnailExpandedModifier(pct: 1))
}
struct ThumbnailExpandedModifier: AnimatableModifier
{
var pct: CGFloat
var animatableData: CGFloat
{
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View
{
return content
.environment(\.modalTransitionPercent, pct)
.opacity(1)
}
}
}
extension EnvironmentValues
{
var modalTransitionPercent: CGFloat
{
get { return self[ModalTransitionKey.self] }
set { self[ModalTransitionKey.self] = newValue }
}
}
public struct ModalTransitionKey: EnvironmentKey
{
public static let defaultValue: CGFloat = 0
}

SwiftUI: Custom button does not recognize touch with clear background and buttonStyle

I stumbled upon a weird behaviour for Buttons in SwiftUI in combination with a custom ButtonStyle.
My target was to create a custom ButtonStyle with some kind of 'push-back animation'. I used the following setup for this:
struct CustomButton<Content: View>: View {
private let content: () -> Content
init(content: #escaping () -> Content) {
self.content = content
}
var body: some View {
VStack {
Button(action: { ... }) {
content()
}
.buttonStyle(PushBackButtonStyle(pushBackScale: 0.9))
}
}
}
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
// Preview
struct Playground_Previews: PreviewProvider {
static var previews: some View {
CustomButton {
VStack(spacing: 10) {
HStack {
Text("Button Text").background(Color.orange)
}
Divider()
HStack {
Text("Detail Text").background(Color.orange)
}
}
}
.background(Color.red)
}
}
When I now try to touch on this button outside of the Text view, nothing will happen. No animation will be visible and the action block will not be called.
What I found out so far:
when you remove the .buttonStyle(...) it does work as expected (no custom animation of course)
or when you set a .background(Color.red)) on the VStack in the CustomButton it does also work as expected in combination with the .buttonStyle(...)
The question now is if anybody have a better idea of how to properly work around this issue or how to fix it?
Just add hit testing content shape in your custom button style, like below
Tested with Xcode 11.4 / iOS 13.4
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.contentShape(Rectangle()) // << fix !!
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
Simply use a .frame and it should work.
To make it easily testable I have rewritten it like this:
struct CustomButton: View {
var body: some View {
Button(action: { }) {
VStack(spacing: 10) {
HStack {
Text("Button Text").background(Color.orange)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.orange)
}
Divider()
HStack {
Text("Detail Text").background(Color.orange)
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.orange)
}
}
}
.buttonStyle(PushBackButtonStyle(pushBackScale: 0.9))
}
}
private struct PushBackButtonStyle: ButtonStyle {
let pushBackScale: CGFloat
func makeBody(configuration: Self.Configuration) -> some View {
configuration
.label
.scaleEffect(configuration.isPressed ? pushBackScale : 1.0)
}
}
I hope I could help. :-)
#Edit With video.

Resources