We are currently using a Picker with a segmented pickerStyle to allow users to change tools. However, we really need to have different tooltips for each button, which doesn't seem to be currently available.
I've been able to create a similar control using an HStack and some buttons and mostly create the same look and feel:
However, the closest I've been able to get to the inset background is to use the .thickMaterial
background, which gives some contrast but doesn't look as good as the inset background of the native Picker control. Is there anyway to mimic the same inset style?
It would also need be nice to get the same 3D look on the selected button if that is possible.
Here is the current code:
HStack {
ForEach(ToolType.allCases, id: \.self) { toolType in
if toolType == toolManager.tool {
Button {
} label: {
Image(systemName: toolType.iconName())
.tint(.white)
}
.frame(width: 44, height: 36)
.background(.thinMaterial)
.mask(Capsule())
.tag(toolType)
.help(toolType.name())
} else {
Button {
toolManager.tool = toolType
} label: {
Image(systemName: toolType.iconName())
.frame(width: 44, height: 36)
.foregroundStyle(.gray)
}
.tag(toolType)
.background(.clear)
.mask(Capsule())
.buttonStyle(.plain)
.help(toolType.name())
}
}
}
.frame(width: 5 * 52, height: 44)
.background(.thickMaterial)
.mask(Capsule())
Here is the code using the native Picker:
Picker("Tools", selection: $toolManager.tool) {
ForEach(ToolType.allCases, id: \.self) { toolType in
Image(systemName: toolType.iconName())
.tag(toolType)
}
}
.pickerStyle(.segmented)
.frame(width: CGFloat(50 * ToolType.allCases.count)) //Space out the buttons a bit.
.help("Tools")
An inset-shadow effect can be achieved by filling a Shape
with .shadow.inner
, see inner(color:radius:x:y:).
Your screenshot of the native picker has a dark inset shadow at the top and a light inset shadow at the bottom. To emulate the same effect:
ZStack
LinearGradient
that fades to transparent as a mask over each shadow layer, to restrict the area in which each shadow is seen.For the buttons themselves:
ButtonStyle
. This way, the ForEach
can be simplified and the buttons do not take on a different shape when being selected.HStack
and then use .matchedGeometryEffect
to match it to the selected button.The button style can be implemented something like this:
struct PickerButtonStyle: ButtonStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isSelected ? .white : .gray)
.frame(width: 44, height: 36)
}
}
When I tried your code for the native picker in a simulator running visionOS 2.1, it didn't look anything like your screenshot. The buttons and the picker background were rounded rectangles and there was no inset shadow. But here is an attempt to emulate the effect in the screenshot you provided:
@Namespace private var ns
HStack {
ForEach(ToolType.allCases, id: \.self) { toolType in
Button {
toolManager.tool = toolType
} label: {
Image(systemName: toolType.iconName())
}
.buttonStyle(PickerButtonStyle(isSelected: toolType == toolManager.tool))
.matchedGeometryEffect(id: toolType, in: ns, isSource: true)
.tag(toolType)
.help(toolType.name())
}
}
.frame(width: 5 * 52, height: 44)
.background {
Capsule()
.fill(Color(white: 0.5))
.shadow(color: .black.opacity(0.3), radius: 1, x: 1, y: 1)
.matchedGeometryEffect(id: toolManager.tool, in: ns, isSource: false)
.animation(.easeInOut, value: toolManager.tool)
}
.background {
ZStack {
Capsule()
.fill()
Capsule()
.fill(.shadow(.inner(color: .white, radius: 1, x: 0, y: 0)))
.mask {
LinearGradient(
colors: [.clear, .black],
startPoint: UnitPoint(x: 0.5, y: 0.7),
endPoint: UnitPoint(x: 0.5, y: 0.9)
)
}
Capsule()
.fill(.shadow(.inner(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)))
.mask {
LinearGradient(
colors: [.black, .clear],
startPoint: UnitPoint(x: 0.5, y: 0.1),
endPoint: UnitPoint(x: 0.5, y: 0.6)
)
}
}
.foregroundStyle(Color(white: 0.4))
}
EDIT If you don't want to see the background move between selections and just want it to fade in instead, then you can go back to having a background behind each button and take out the .matchedGeometryEffect
(and the namespace):
struct PickerButtonStyle: ButtonStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(isSelected ? .white : .gray)
.frame(width: 44, height: 36)
.background {
if isSelected {
Capsule()
.fill(Color(white: 0.5))
.shadow(color: .black.opacity(0.3), radius: 1, x: 1, y: 1)
}
}
}
}
HStack {
ForEach(ToolType.allCases, id: \.self) { toolType in
Button {
toolManager.tool = toolType
} label: {
Image(systemName: toolType.iconName())
}
.buttonStyle(PickerButtonStyle(isSelected: toolType == toolManager.tool))
.tag(toolType)
.help(toolType.name())
}
}
.frame(width: 5 * 52, height: 44)
.background {
ZStack {
// as before
}
.foregroundStyle(Color(white: 0.4))
}