Accessibility of Image in Button in ToolbarItem - swift

I am adding accessibility into my SwiftUI app, until I encountered a problem when adding an accessibilityLabel(_:) to a Button in a ToolbarItem. Here is some sample code:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Content")
.accessibilityElement()
.accessibilityLabel("Content label") // This is here just to show Voice Control is working
.navigationTitle("Test")
.toolbar {
// Comment parts out below, depending on what you want to test
ToolbarItem(placement: .navigationBarTrailing) {
// What I want, but doesn't work:
// (Also tried adding the label to either the button
// label or the whole button itself, neither works.)
Button {
print("Pressed")
} label: {
Image(systemName: "plus")
.accessibilityElement()
.accessibilityLabel("Some label")
}
.accessibilityElement()
.accessibilityLabel("Some other label")
// What I don't want, but does work:
Image(systemName: "plus")
.accessibilityLabel("Another label")
}
}
}
}
}
I am testing the accessibility with Voice Control. What is strange is that the accessibility label works for the image in the toolbar item, but not when inside a button in the toolbar item.
When I say the accessibility label doesn't work, it says "Add" instead of the expected label. I assume SwiftUI creates this label by default for the system image "plus", but I would like to change it.
The button accessibility labels also work when not in a toolbar item. Is this a bug, or some issue I have caused?

SwiftUI treats single toolbar items differently (applies their own style, size etc). It looks like this applies to accessibility labels as well.
Fortunately, there is a workaround - see SwiftUI Xcode 12.3 can't change button size in toolbar.
In your case, the code should look like:
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Text("")
.accessibilityHidden(true)
Button {
print("Pressed")
} label: {
Image(systemName: "plus")
.accessibilityElement()
.accessibilityLabel("Some label")
}
}
}
}
(accessibilityLabel can be attached either to the Image or to the Button.)
Tested with Xcode 12.3, iOS 14.3.

Update: this has been fixed for iOS 15+.
My radar had 'Less than 10' similar reports, but is now actually fixed for iOS 15+. However, if you are supporting older versions, see the answer above by #pawello2222 and here is an updated version to only do the workaround if necessary:
/// Embeds the content in a view which removes some
/// default styling in toolbars, so accessibility works.
/// - Returns: Embedded content.
#ViewBuilder func embedToolbarContent() -> some View {
if #available(iOS 15, *) {
self
} else {
HStack(spacing: 0) {
Text("")
.frame(width: 0, height: 0)
.accessibilityHidden(true)
self
}
}
}

Related

Color in ZStack not behaving properly

so I'd like to lay a Color at the top of a ZStack over another view. The reason I don't want to use overlay is the Color is going to have a tap gesture attached to it. A minimal reproducible example is below. Essentially, I want the Color.secondary to be confined to the same area as the HStack (without explicitly setting frame size. Here's the code:
struct ContentView: View {
var body: some View {
ZStack {
HStack {
Spacer()
Button(action: {
print("tapped button")
}, label: {
Text("button")
})
}.background(Color.red)
Color.secondary
.onTapGesture {
print("clicked color")
}
}
}
}
So I'd like the view to just be a white screen, with an HStack that looks slightly darker red.
Below is a picture of my UI. The view is greyed out during onboarding, and the user will essentially just tap the grey area to go to the next step in the onboarding. If I attach a tap gesture to the Color view and then just hide the color view according to state changes, this isn't a problem. After the onboarding is completed, the greyed area won't be there and the buttons underneath need to be interactable. When using overlays, AFTER onboarding, I don't want tapping anywhere on the view to change the app state.
https://i.stack.imgur.com/lphHg.png
Given your further description, I believe you have the onTapGesture(perform:) in the wrong place, outside of the overlay rather than inside.
In the code below, the onTapGesture(perform:) can only be tapped on the gray overlay when it is showing. However when it is attached after the overlay, it can be tapped on when not tapping on something like the button.
Working code:
struct ContentView: View {
#AppStorage("is_onboarding") var isOnboarding = true
var body: some View {
HStack {
Spacer()
Button {
print("tapped button")
} label: {
Text("button")
}
}
.background(Color.red)
.overlay {
if isOnboarding {
Color.secondary
.onTapGesture {
print("clicked color")
isOnboarding = false
}
}
}
}
}
If on iOS 14+ not iOS 15+, use the following overlay instead:
.overlay(
isOnboarding ?
Color.secondary
.onTapGesture {
print("clicked color")
isOnboarding = false
}
: nil
)

Button grayed out in toolbar initially

I have a SwiftUI macOS application, and I noticed that when you have a button in a toolbar, its image is initially grayed out until you hover over or click it.
I am using macOS Montery Beta 3, and Xcode 13 Beta 3.
Simple Reproduction
Create a new SwiftUI macOS project
Paste the following code:
struct ContentView: View {
var body: some View {
NavigationView {
Text("Panel1").padding()
.toolbar {
Button(action: {}) {
Image(systemName: "plus")
}
}
Text("Panel2").padding()
}
}
}
Run the application, and you should see that the plus button is grayed out. However hovering over or clicking it makes the button not gray.
This question is very similar to this, but instead with a toolbar.
Whats happening here?
I had the same issue and discovered that you need to specify the color explicitly.
.toolbar {
Button(action: {}) {
Image(systemName: "plus")
}
Button(action: {}) {
Image(systemName: "plus")
.foregroundColor(.white)
}
}

Creating a navbar with 3 items

I'm trying to create something like this:
A navigation bar with 3 items, is it possible to do this using navigationBarItems?
My current plan is to hide the navbar using:
.navigationBarTitle("")
.navigationBarHidden(true)
and then creating the 3 buttons using a HStack. The Problem I have is because I'm hiding the navbar, the click of one of the buttons take it to another view, which also then hides the navbar (Thats not what im looking for)
I have tried:
.navigationBarItems(trailing:
HStack {
Button("About") {
print("About tapped!")
}
Button("Help") {
print("Help tapped!")
}
}
)
But this creates the two items next to each other on the right side. I tried putting a Spacer() in the above HStack, but this doesn't work.
I would prefer to use navigationBarItems but can't seem to find a way to centre an item?
A navigation bar with 3 items, is it possible to do this using navigationBarItems?
No. Moreover navigationBarItems modifier is deprecated since SwiftUI 2.0
SwiftUI 2.0
This can be done with toolbar modifier as easy as attach it to any view inside NavigationView
Demo prepared & tested with Xcode 12 / iOS 14:
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: {}) { Image(systemName: "gear") }
}
ToolbarItem(placement: .principal) {
Button(action: {}) { Image(systemName: "car") }
}
ToolbarItem(placement: .navigation) {
Button(action: {}) { Image(systemName: "chevron.left") }
}

SwiftUI List disclosure indicator without NavigationLink

I am searching for a solution to show the disclosure indicator chevron without having the need to wrap my view into an NavigationLink. For example I want to show the indicator but not navigate to a new view but instead show a modal for example.
I have found a lot solutions that hide the indicator button but none which explains how to add one. Is this even possible in the current SwiftUI version ?
struct MyList: View {
var body: some View {
NavigationView {
List {
Section {
Text("Item 1")
Text("Item 2")
Text("Item 3")
Text("Item 4")
}
}
}
}
For example I want to add the disclosure indicator to Item 1 without needing to wrap it into an NavigationLink
I already tried to fake the indicator with the chevron.right SF Symbol, but the symbol does not match 100% the default iOS one. Top is default bottom is chevron.right.
It is definitely possible.
You can use a combination of Button and a non-functional NavigationLink to achieve what you want.
Add the following extension on NavigationLink.
extension NavigationLink where Label == EmptyView, Destination == EmptyView {
/// Useful in cases where a `NavigationLink` is needed but there should not be
/// a destination. e.g. for programmatic navigation.
static var empty: NavigationLink {
self.init(destination: EmptyView(), label: { EmptyView() })
}
}
Then, in your List, you can do something like this for the row:
// ...
ForEach(section.items) { item in
Button(action: {
// your custom navigation / action goes here
}) {
HStack {
Text(item.name)
Spacer()
NavigationLink.empty
}
}
}
// ...
The above produces the same result as if you had used a NavigationLink and also highlights / dehighlights the row as expected on interactions.
Hopefully, this is what you are looking for. You can add the item to a HStack and with a Spacer in between fake it that its a Link:
HStack {
Text("Item 1")
Spacer()
Button(action: {
}){
Image(systemName: "chevron.right")
.font(.body)
}
}
The answers already submitted don't account for one thing: the highlighting of the cell when it is tapped. See the About Peek-a-View cell in the image at the bottom of my answer — it is being highlighted because I was pressing it when the screenshot was taken.
My solution accounts for both this and the chevron:
Button(action: { /* handle the tap here */ }) {
NavigationLink("Cell title", destination: EmptyView())
}
.foregroundColor(Color(uiColor: .label))
The presence of the Button seems to inform SwiftUI when the cell is being tapped; simply adding an onTapGesture() is not enough.
The only downside to this approach is that specifying the .foregroundColor() is required; without it, the button text will be blue instead.
in iOS15 the following is a better match as the other solutions were little too big and not bold enough. it'll also resize better to different Display scales better than specifying font sizes.
HStack {
Text("Label")
Spacer()
Image(systemName: "chevron.forward")
.font(Font.system(.caption).weight(.bold))
.foregroundColor(Color(UIColor.tertiaryLabel))
}
Would be good if there was an offical way of doing this. Updating every OS tweak is annoying.
I found an original looking solution. Inserting the icon by hand does not bring the exact same look.
The trick is to use the initializer with the "isActive" parameter and pass a local binding which is always false. So the NavigationLink waits for a programmatically trigger event which will never occur.
// use this initializer
NavigationLink(isActive: <Binding<Bool>>, destination: <() -> _>, label: <() -> _>)
You can pass an empty closure to the destination parameter. It will never get called anyway. To do some action you put a button on top within a ZStack.
func navigationLinkStyle() -> some View {
let never = Binding<Bool> { false } set: { _ in }
return ZStack {
NavigationLink(isActive: never, destination: { }) {
Text("Item 1") // your list cell view
}
Button {
// do your action on tap gesture
} label: {
EmptyView() // invisible placeholder
}
}
}
For accessibility you might need to mimic UIKit version of disclosure indicator. You don't need to implement it this way per se but if you use e.g. Appium for testing you might want to have it like this to keep tests succeeding
Apparently UIKit's disclosure indicator is a disabled button with some accessibility values so here's the solution:
struct DisclosureIndicator: View {
var body: some View {
Button {
} label: {
Image(systemName: "chevron.right")
.font(.body)
.foregroundColor(Color(UIColor.tertiaryLabel))
}
.disabled(true)
.accessibilityLabel(Text("chevron"))
.accessibilityIdentifier("chevron")
.accessibilityHidden(true)
}
}
Or maybe create a fake one and use it, even if you tap you can call your events.
NavigationLink(destination: EmptyView()) {
HStack {
Circle()
Text("TITLE")
}
}
.contentShape(Rectangle())
.onTapGesture {
print("ALERT MAYBE")
}
I created a custom NavigationLink that:
Adds an action API (instead of having to push a View)
Shows the disclosure indicator
Ensures that List cell selection remains as-is
Usage
MYNavigationLink(action: {
didSelectCell()
}) {
MYCellView()
}
Code
import SwiftUI
struct MYNavigationLink<Label: View>: View {
#Environment(\.colorScheme) var colorScheme
private let action: () -> Void
private let label: () -> Label
init(action: #escaping () -> Void, #ViewBuilder label: #escaping () -> Label) {
self.action = action
self.label = label
}
var body: some View {
Button(action: action) {
HStack(spacing: 0) {
label()
Spacer()
NavigationLink.empty
.layoutPriority(-1) // prioritize `label`
}
}
// Fix the `tint` color that `Button` adds
.tint(colorScheme == .dark ? .white : .black) // TODO: Change this for your app
}
}
// Inspiration:
// - https://stackoverflow.com/a/66891173/826435
private extension NavigationLink where Label == EmptyView, Destination == EmptyView {
static var empty: NavigationLink {
self.init(destination: EmptyView(), label: { EmptyView() })
}
}

Custom Button in SwiftUI List

SwiftUI Custom Button in List
I'm trying to create a custom button in a SwiftUI List. I want it to have a blue background with white text, and importantly, to remain blue and go to 50% opacity when pressed, not the default grey.
I tried using a custom ButtonStyle, but when I do so, the tappable area of the button is reduced to just the label itself. If I tap any other part of the cell, the colour doesn't change. If I remove the ButtonStyle, tapping anywhere on the cell works
How can I fix this so that I get my custom colours, including the colour when tapped, but the whole cell is still tappable?
import SwiftUI
struct BlueButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(configuration.isPressed ? Color.white.opacity(0.5) : Color.white)
.listRowBackground(configuration.isPressed ? Color.blue.opacity(0.5) : Color.blue)
}
}
struct ExampleView: View {
var body: some View {
NavigationView {
List {
Section {
Text("Info")
}
Section {
Button(action: {print("pressed")})
{
HStack {
Spacer()
Text("Save")
Spacer()
}
}.buttonStyle(BlueButtonStyle())
}
}
.listStyle(GroupedListStyle())
.environment(\.horizontalSizeClass, .regular)
.navigationBarTitle(Text("Title"))
}
}
}
struct ExampleView_Previews: PreviewProvider {
static var previews: some View {
ExampleView()
}
}
In standard variant List intercepts and handles content area of tap detection, in your custom style it is defined, by default, by opaque area, which is only text in your case, so corrected style is
Update for: Xcode 13.3 / iOS 15.4
It looks like Apple broken something, because listRowBackground now works only inside List itself, no subview, which is senseless from generic concept of SwiftUI.
Updated solution with same behavior as on demo
Original for: Xcode 11.4 / iOS 13.4
struct BlueButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.font(.headline)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.contentShape(Rectangle())
.foregroundColor(configuration.isPressed ? Color.white.opacity(0.5) : Color.white)
.listRowBackground(configuration.isPressed ? Color.blue.opacity(0.5) : Color.blue)
}
}
and usage, just
Button(action: {print("pressed")})
{
Text("Save")
}.buttonStyle(BlueButtonStyle())
and even
Button("Save") { print("pressed") }
.buttonStyle(BlueButtonStyle())