Search code examples
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:

    • use a shape that is filled with a solid color as the base layer of a ZStack
    • add the inset shadows as separate layers, one light, the other dark
    • apply a 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:

    • The best way to apply the styling to the buttons is to create a custom ButtonStyle. This way, the ForEach can be simplified and the buttons do not take on a different shape when being selected.
    • The selected button has a shadow. This can be applied as a simple drop-shadow to the background shape.
    • If you want the background shape to move between the buttons when the selection changes, it works well to show it in the background of the 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))
    }
    

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