I built a custom picker that reveals the picker items when tapped. Now I'm trying to use two of those pickers in another view that only shows the picker that the user is currently interacting with and hides the other one but there is a glitch in the picker animation such that hiding the picker pauses for a moment before completely disappearing.
Here is a screen recording of the behaviour - https://imgur.com/a/gItlRIt
Here is the code below:
struct ConceptOptionsView: View {
@State private var aspectRatio: AspectRatio = .sixteenbynine
@State private var imageModel: ImageModel = .flux(.schnell)
@State private var resolution: ImageResolution = .seventwenty
@State private var activePicker: ActivePicker? = nil
enum ActivePicker {
case model
case resolution
}
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 0.5)
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 20) {
HStack {
if activePicker == nil || activePicker == .model {
PillSelectorView(
title: "Model",
icon: "sparkle",
selection: $imageModel,
items: ImageModel.pickerItems,
didBeginSelection: {
activePicker = .model
},
didEndSelection: {
activePicker = nil
}
)
.transition(.move(edge: .leading))
}
Spacer()
if activePicker == nil || activePicker == .resolution {
PillSelectorView(
title: "Resolution",
icon: "camera.metering.center.weighted.average",
selection: $resolution,
items: ImageResolution.pickerItems,
didBeginSelection: { activePicker = .resolution },
didEndSelection: { activePicker = nil }
)
.transition(.move(edge: .trailing))
}
}
Text("The options you select will be the starting point when you want to generate a new image")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
.padding()
Spacer()
}
.background(.ultraThinMaterial)
.animation(.snappy, value: activePicker)
}
}
struct PillSelectorItem<SelectionValue: Hashable>: Identifiable {
let id = UUID()
let value: SelectionValue
let title: String
init(
value: SelectionValue,
title: String
) {
self.value = value
self.title = title
}
}
struct PillSelectorView<SelectionValue: Hashable>: View {
let title: String
let icon: String
@Binding var selection: SelectionValue
let items: [PillSelectorItem<SelectionValue>]
var didBeginSelection: () -> Void
var didEndSelection: () -> Void
@State private var isSelecting: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.leading, 10)
HStack {
Button {
withAnimation {
isSelecting.toggle()
}
} label: {
HStack {
Image(systemName: icon)
.imageScale(.small)
if !isSelecting {
Text(items.first(where: { $0.value == selection })?.title ?? "Item")
.font(.subheadline.weight(.medium))
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.frame(maxWidth: isSelecting ? 40 : .infinity)
.background(.gray.opacity(0.1), in: .rect(cornerRadius: 20))
}
.buttonStyle(.plain)
if isSelecting {
ScrollView(.horizontal) {
HStack {
ForEach(items) { item in
Button {
withAnimation {
selection = item.value
isSelecting = false
}
} label: {
Text(item.title)
.font(.footnote.weight(.medium))
.foregroundStyle(
item.value == selection ? .primary : .secondary
)
.padding(.vertical, 10)
.padding(.horizontal, 10)
.overlay {
Capsule()
.fill(
item.value == selection ? .gray.opacity(0.1) : .clear
)
.stroke(
item.value == selection
? .primary : Color.gray.opacity(0.3), lineWidth: 1.0
)
}
}
.buttonStyle(.plain)
}
}
}
.scrollIndicators(.hidden)
.transition(.move(edge: .trailing))
}
}
}
.onChange(of: isSelecting) { oldValue, newValue in
newValue ? didBeginSelection() : didEndSelection()
}
}
}
Inside ConceptOptionsView
, you are using a .move
transition for the PillSelectorView
as they are revealed and hidden. But inside PillSelectorView
there is another .move
transition on the ScrollView
. So there is some duplication here.
The animation can be improved with the following changes:
ConceptOptionsView
, change both the transitions to .opacity
. For example:PillSelectorView(
// ...
)
.transition(.opacity)
// .transition(.move(edge: .leading))
PillSelectorView
, combine the .move
transition with .opacity
:ScrollView(.horizontal) {
// ...
}
.scrollIndicators(.hidden)
.transition(.move(edge: .trailing).combined(with: .opacity))
PillSelectorView
, add the modifier .drawingGroup()
to the label of the Button
. This keeps the text together with the shape while the animation is happening:Text(item.title)
// + all existing modifiers
.drawingGroup()
Alternatively, if your target is iOS 17 or above, you can add the modifier .geometryGroup()
to the ScrollView
instead. This actually works a bit better.
Here is how it looks with these changes:
Btw, it seems a bit strange to be showing the capsule shape as an overlay with a semi-transparent fill. This may be why the pill labels have a fuzzy border in the middle. You might want to consider showing the shape in the background of the label instead. Better still, use a custom ButtonStyle
.
Also, you will notice that the text below the picker is moving up when isSelecting
is true. The reason is because a different font size is being used for this mode (.footnote
instead of .subheadline
) and the vertical padding is also different (10 instead of 12).