How do I present a SwiftUI context menu conditionally? - swiftui

Consider the following view code:
Text("Something")
.contextMenu {
// Some menu options
}
This works fine. What I would like to do: present the contextMenu through a view modifier indirection. Something like this:
Text("Something")
.modifier(myContextMenu) {
// Some menu options
}
Why: I need to do some logic inside the modifier to conditionally present or not present the menu. I can’t work out the correct view modifier signature for it.
There is another contextMenu modifier available which claims that I can conditionally present the context menu for it. Upon trying this out, this does not help me, because as soon as I add contextMenu modifier to a NavigationLink on iOS, the tap gesture on it stops working. There is discussion in a response below.
How do I present a context menu using a view modifier?

Here is a demo for optional context menu usage (tested with Xcode 11.2 / iOS 13.2)
struct TestConditionalContextMenu: View {
#State private var hasContextMenu = false
var body: some View {
VStack {
Button(hasContextMenu ? "Disable Menu" : "Enable Menu")
{ self.hasContextMenu.toggle() }
Divider()
Text("Hello, World!")
.background(Color.yellow)
.contextMenu(self.hasContextMenu ?
ContextMenu {
Button("Do something1") {}
Button("Do something2") {}
} : nil)
}
}
}

Something like this?
Text("Options")
.contextMenu {
if (1 == 0) { // some if statements here
Button(action: {
//
}) {
Text("Choose Country")
Image(systemName: "globe")
}
}
}

Here’s what I came up with. Not entirely satisfied, it could be more compact, but it works as expected.
struct ListView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: ItemView(item: "Something")) {
Text("Something").modifier(withiOSContextMenu())
}.modifier(withOSXContextMenu())
}
}
}
}
struct withOSXContextMenu: ViewModifier {
func body(content: Content) -> some View {
#if os(OSX)
return content.contextMenu(ContextMenu {
ContextMenuContent()
})
#else
return content
#endif
}
}
struct withiOSContextMenu: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
return content.contextMenu(ContextMenu {
ContextMenuContent()
})
#else
return content
#endif
}
}
func ContextMenuContent() -> some View {
Group {
Button("Click me") {
print("Button clicked")
}
Button("Another button") {
print("Another button clicked")
}
}
}

Related

How to add conditional ToolbarItems to NavigationView Toolbar with SwiftUI?

I am trying to add an icon button to the leading edge of a NavigationView's toolbar - but I want that button to only be visible when the device is in landscape mode. (Kinda like how the default Notes app works)
I have the following code:
var body: some View {
VStack {
// ... content display
}
.toolbar {
if UIDevice.current.orientation.isLandscape {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: toggleFullscreen) {
if isFullscreen {
Image(systemName: "arrow.down.right.and.arrow.up.left")
} else {
Image(systemName: "arrow.up.left.and.arrow.down.right")
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: createNewNote) {
Image(systemName: "square.and.pencil")
}
}
})
}
If I remove the conditional if clause within the toolbar content, it works fine, but it seems to not recognize the if statement at all?
However, the if statement in the VStack works fine.
The specific error I get is:
Closure containing control flow statement cannot be used with result builder 'ToolbarContentBuilder'
Any idea how to fix this?
P.S. Consider me a complete SwiftUI noob. Although, I am proficient in React - so any React analogies to help me understand would be much appreciated.
You're detecting the device orientation in a wrong way SwiftUI One way you can achieve that is by using a custom modifier to detect device rotation:
Cfr: https://www.hackingwithswift.com/quick-start/swiftui/how-to-detect-device-rotation
The code would look like this:
1. Create custom modifier: Detecting Device Rotation
// Our custom view modifier to track rotation and call our action
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)
}
}
}
// A View wrapper to make the modifier easier to use
extension View {
func onRotate(perform action: #escaping (UIDeviceOrientation) -> Void) -> some View {
self.modifier(DeviceRotationViewModifier(action: action))
}
}
2. Use the created modifier:
struct ContentView: View {
#State private var isFullscreen = false
#State private var orientation = UIDeviceOrientation.unknown
var body: some View {
NavigationView {
VStack {
// ... content display
}
.onRotate { orientation = $0 }
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
if orientation.isLandscape {
Button(action: {}) {
if isFullscreen {
Image(systemName: "arrow.down.right.and.arrow.up.left")
} else {
Image(systemName: "arrow.up.left.and.arrow.down.right")
}
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {}) {
Image(systemName: "square.and.pencil")
}
}
}
}
}
}

Context menu in SwiftUI with nested views

Looks like there's a problem when using the .contextMenu view modifier with nested views.
Here's sample code showing the problem:
import SwiftUI
enum SampleEnum: String, CaseIterable {
case one, two, three, four
}
struct ContentView: View {
var body: some View {
Form {
Section {
VStack {
HStack {
ForEach(SampleEnum.allCases, id:\.self) { id in
Text(id.rawValue)
.contextMenu {
Button {
print("Change country setting")
} label: {
Label("Choose Country", systemImage: "globe")
}
}
}
}
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here's the result:
So, it doesn't appear that a context menu can be performed on a single Text view since the entire section/stack is selected.
Is there any way to get the contextMenu to work on an individual Text view in such a nested layout?
Try this workaround, I changed contextMenu to Menu() and passed your Text() as its param, made each text has its own menu as you wanted and prints each ID correctly as a proof that each action is independent to each Text()
Code:
struct ContentView: View {
var body: some View {
Form {
Section {
VStack {
HStack {
ForEach(SampleEnum.allCases, id:\.self) { id in
Menu("\(Text(id.rawValue))") {
Button {
print("Change country setting")
print(id)
} label: {
Label("Choose Country", systemImage: "globe")
}
}
}
}
}
}
}
}
}

How to add a custom tap handler on a custom SwiftUI View?

List {
ItemView(item: item)
.myCustomTapHandler {
print("ItemView was tapped, triggered from List!")
}
}
}
struct ItemView: View {
var body: some View {
VStack {
Button(action: {
// this should fire myCustomTapHandler
}) {
Text("Hello world")
}
}
}
I have a custom ItemView with a simple button. I want to re create the same trailing closure syntax as .onTapGesture, with only triggering when you tap the Button. This will be named .myCustomTapHandler How to do this in SwiftUI?
What you are describing (The dot) is a ViewModifier
struct MyItemListView: View {
var body: some View {
List {
//Using a ViewModifier you make the whole View tappable not just the button
MyItemView(item: 2)
.myCustomTapHandler{
print("ItemView has custom modifier that makes the whole ItemView tappable")
}
}
}
}
struct MyCustomTapHandler: ViewModifier {
var myCustomTapHandler: () -> Void
func body(content: Content) -> some View {
content
//Add the onTap to the whole View
.onTapGesture {
myCustomTapHandler()
}
}
}
extension View {
func myCustomTapHandler(myCustomTapHandler: #escaping () -> Void) -> some View {
modifier(MyCustomTapHandler(myCustomTapHandler: myCustomTapHandler))
}
}
The ViewModifier affects the entire View not just the Button.
But, unless you are doing something else it is just an onTapGesture.
This is likely not the best solution because with the Button in the ItemView you will have inconsistent results.
Sometimes the Button will get the tap and sometimes the ViewModifier will get the tap and given that the View is in a List it will likely make the whole tapping confusing because the List has properties that make the whole row tappable anyway vs just the Text of the `Button
If you want the Button to perform an action that is defined in the ListView you can pass it as a parameter.
This will likely give you the best results.
struct MyItemListView: View {
var body: some View {
VStack {
//This passes the custom action to the button
MyItemView(item: 1){
print("Button needs to be tapped to trigger this")
}
}
}
}
struct MyItemView: View {
let item: Int
var myCustomTapHandler: () -> Void
var body: some View {
VStack {
Button(action: {
myCustomTapHandler()
}) {
Text("Hello world")
}
}
}
}
You can add like this.
struct ItemView: View {
private var action: (() -> Void)? = nil
var body: some View {
VStack {
Button(action: {
action?()
}) {
Text("Hello world")
}
}
}
func myCustomTapHandler(onAction: #escaping () -> Void) -> Self {
var view = self
view.action = onAction
return view
}
}
Usage
struct ContentView: View {
var body: some View {
ItemView()
.myCustomTapHandler {
print("Hello word")
}
}
}
Another way is...
I don't think with dot property you will get action. You need closure inside the ItemView.
Like this
struct ItemView: View {
var action: () -> Void
var body: some View {
VStack {
Button(action: action) {
Text("Hello world")
}
}
}
}
Usage
List {
ItemView {
// Here you will get action
print("ItemView was tapped, triggered from List!")
}
}

How can I use multiple fullScreenCover in IOS14

I want to present the two destinations view in full screen mode from a single view.
Below is a sample of my code. Seem that the function only works for single presentation, if I have a second fullScreenCover defined, the first fullScreenCover didn't work properly.Is that any workaround at this moment?
import SwiftUI
struct TesFullScreen: View {
init(game : Int){
print(game)
}
var body: some View {
Text("Full Screen")
}
}
ContentView
import SwiftUI
struct ContentView: View {
#State var showFullScreen1 : Bool = false
#State var showFullScreen2 : Bool = false
var body: some View {
NavigationView {
VStack {
Spacer()
Button(action: { self.showFullScreen1 = true }) {
Text("Show Full Screen 1")
}
Button(action: { self.showFullScreen2 = true }) {
Text("Show Full Screen 2")
}
Spacer()
}
.navigationBarTitle("TextBugs", displayMode: .inline)
}
.fullScreenCover(isPresented: self.$showFullScreen1){
TesFullScreen(game: 1)
}
.fullScreenCover(isPresented: self.$showFullScreen2){
TesFullScreen(game: 2)
}
}
}
Not always the accepted answer works (for example if you have a ScrollView with subviews (cells in former days) which holds the buttons, that set the navigational flags).
But I found out, that you also can add the fullScreen-modifier onto an EmptyView. This code worked for me:
// IMPORTANT: Has to be within a container (e.g. VStack, HStack, ZStack, ...)
if myNavigation.flag1 || myNavigation.flag2 {
EmptyView().fullScreenCover(isPresented: $myNavigation.flag1)
{ MailComposer() }
EmptyView().fullScreenCover(isPresented: $myNavigation.flag2)
{ RatingStore() }
}
Usually some same modifier added one after another is ignored. So the simplest fix is to attach them to different views, like
struct FullSContentView: View {
#State var showFullScreen1 : Bool = false
#State var showFullScreen2 : Bool = false
var body: some View {
NavigationView {
VStack {
Spacer()
Button(action: { self.showFullScreen1 = true }) {
Text("Show Full Screen 1")
}
.fullScreenCover(isPresented: self.$showFullScreen1){
Text("TesFullScreen(game: 1)")
}
Button(action: { self.showFullScreen2 = true }) {
Text("Show Full Screen 2")
}
.fullScreenCover(isPresented: self.$showFullScreen2){
Text("TesFullScreen(game: 2)")
}
Spacer()
}
.navigationBarTitle("TextBugs", displayMode: .inline)
}
}
}
Alternate is to have one .fullScreenCover(item:... modifier and show inside different views depending on input item.
The only thing that worked for me was the answer in this link:
https://forums.swift.org/t/multiple-sheet-view-modifiers-on-the-same-view/35267
Using the EmptyView method or other solutions always broke a transition animation on one of the two presentations. Either transitioning to or from that view and depending on what order I chose them.
Using the approach by Lantua in the link which is using the item argument instead of isPresented worked in all cases:
enum SheetChoice: Hashable, Identifiable {
case a, b
var id: SheetChoice { self }
}
struct ContentView: View {
#State var sheetState: SheetChoice?
var body: some View {
VStack {
...
}
.sheet(item: $sheetState) { item in
if item == .a {
Text("A")
} else {
Text("B")
}
}
}
}
The sheetState needs to be optional for it to work.

Highlight SwiftUI Button programmatically

I've been trying to highlight programmatically a SwiftUI Button, without success so far…
Of course I could implement the whole thing again, but I'd really like to take advantage of what SwiftUI already offers. I would imagine that there is an environment or state variable that we can mutate, but I couldn't find any of those.
Here's a small example of what I would like to achieve:
struct ContentView: View {
#State private var highlighted = false
var body: some View {
Button("My Button", action: doSomething())
.highlighted($highlighted) // <-- ??
Button("Toggle highlight") {
highlighted.toggle()
}
}
func doSomething() { ... }
}
It seems very odd that something so simple if not within easy reach in SwiftUI. Has anyone found a solution?
Here is a demo of possible approach, based on custom ButtonStyle. Tested with Xcode 11.4 / iOS 13.4
struct HighlightButtonStyle: ButtonStyle {
let highlighted: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(highlighted || configuration.isPressed ? Color.yellow : Color.clear)
}
}
extension Button {
func highlighted(_ flag: Bool) -> some View {
self.buttonStyle(HighlightButtonStyle(highlighted: flag))
}
}
struct ContentView: View {
#State private var highlighted = false
var body: some View {
VStack {
Button("My Button", action: doSomething)
.highlighted(highlighted)
Divider()
Button("Toggle highlight") {
self.highlighted.toggle()
}
}
}
func doSomething() { }
}