swiftswiftuivisionos

Inset Style for View


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.

Segmented Picker

I've been able to create a similar control using an HStack and some buttons and mostly create the same look and feel:

Custom Picker

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")

Solution

  • 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:

    For the buttons themselves:

    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))
    }
    

    Screenshot


    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))
    }