From babdf6468a9fc43e2c2ce89b8425bea9be74d5fd Mon Sep 17 00:00:00 2001 From: Abe White Date: Tue, 10 Sep 2024 16:51:32 -0500 Subject: [PATCH] Optimize Picker performance for ForEach content without explicit .tags --- .../SkipUI/SkipUI/Containers/ForEach.swift | 106 +++++++++-- .../SkipUI/Containers/LazySupport.swift | 7 +- Sources/SkipUI/SkipUI/Containers/List.swift | 20 +- Sources/SkipUI/SkipUI/Controls/Picker.swift | 174 ++++++++++++------ 4 files changed, 223 insertions(+), 84 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Containers/ForEach.swift b/Sources/SkipUI/SkipUI/Containers/ForEach.swift index 58c376aa..e2c44dc6 100644 --- a/Sources/SkipUI/SkipUI/Containers/ForEach.swift +++ b/Sources/SkipUI/SkipUI/Containers/ForEach.swift @@ -46,7 +46,7 @@ public final class ForEach : View, LazyItemFactory { @Composable public override func Compose(context: ComposeContext) -> ComposeResult { // We typically want to be transparent and act as though our loop were unrolled. The exception is when we need // to act as a lazy item factory - if context.composer is LazyItemCollectingComposer { + if context.composer is ForEachComposer { return super.Compose(context: context) } else { ComposeContent(context: context) @@ -84,24 +84,13 @@ public final class ForEach : View, LazyItemFactory { } } - @Composable private func taggedViews(for views: [View], defaultTag: Any?, context: ComposeContext) -> [View] { - return views.map { view in - if let taggedView = TagModifierView.strip(from: view, role: ComposeModifierRole.tag) { - return taggedView - } else if let defaultTag { - return TagModifierView(view: view, value: defaultTag, role: ComposeModifierRole.tag) - } else { - return view - } - } - } - @Composable func appendLazyItemViews(to views: MutableList, appendingContext: ComposeContext) -> ComposeResult { // ForEach views might contain nested lazy item factories such as Sections or other ForEach instances. They also // might contain more than one view per iteration, which isn't supported by Compose lazy processing. We execute // our content closure for the first item in the ForEach and examine its content to see if it should be unrolled // If it should, we perform the full ForEach to append all items. If not, we append ourselves instead so that we // can take advantage of Compose's ability to specify ranges of items + let isTagging = EnvironmentValues.shared._placement.contains(ViewPlacement.tagged) var isFirstView = true if let indexRange { for index in indexRange { @@ -112,7 +101,7 @@ public final class ForEach : View, LazyItemFactory { } else { isFirstView = false } - if let identifier { + if isTagging { contentViews = taggedViews(for: contentViews, defaultTag: index, context: appendingContext) } contentViews.forEach { $0.Compose(appendingContext) } @@ -126,7 +115,7 @@ public final class ForEach : View, LazyItemFactory { } else { isFirstView = false } - if let identifier { + if isTagging, let identifier { contentViews = taggedViews(for: contentViews, defaultTag: identifier(object), context: appendingContext) } contentViews.forEach { $0.Compose(appendingContext) } @@ -141,7 +130,7 @@ public final class ForEach : View, LazyItemFactory { } else { isFirstView = false } - if let identifier { + if isTagging, let identifier { contentViews = taggedViews(for: contentViews, defaultTag: identifier(objects[i]), context: appendingContext) } contentViews.forEach { $0.Compose(appendingContext) } @@ -150,6 +139,53 @@ public final class ForEach : View, LazyItemFactory { return ComposeResult.ok } + /// If there aren't explicit `.tag` modifiers on `ForEach` content, we can potentially find the matching view for a tag + /// without having to unroll the entire loop. + /// + /// - Seealso: `Picker` + @Composable func untaggedView(forTag tag: Any?, context: ComposeContext) -> View? { + // Evaluate the view generated by the first item to see if our body produces tagged views + var firstView: View? = nil + if let indexRange, let first = indexRange.first { + firstView = indexedContent!(first) + } else if let objects, let first = objects.first { + firstView = objectContent!(first) + } else if let objectsBinding { + let objects = objectsBinding.wrappedValue + if !objects.isEmpty { + firstView = objectsBindingContent!(objectsBinding, 0) + } + } + guard let firstView else { + return nil + } + let firstViews = collectViews(from: firstView, context: context) + guard !firstViews.contains(where: { TagModifierView.strip(from: $0, role: .tag) != nil }) else { + return nil + } + + // If we do not produce tagged views, then we can match the supplied tag against our id function + if let indexRange, let index = tag as? Int, indexRange.contains(index) { + return indexedContent!(index) + } else if let objects, let identifier { + for object in objects { + let id = identifier(object) + if id == tag { + return objectContent!(object) + } + } + } else if let objectsBinding, let identifier { + let objects = objectsBinding.wrappedValue + for i in 0.. Bool { // If we're past the first view where we make the unroll decision, we must be unrolling guard isFirstView else { @@ -162,17 +198,46 @@ public final class ForEach : View, LazyItemFactory { override func composeLazyItems(context: LazyItemFactoryContext) { if let indexRange { - context.indexedItems(indexRange, identifier, onDeleteAction, onMoveAction, indexedContent!) + let factory: (Int) -> View = context.isTagging ? { index in + return TagModifierView(view: indexedContent!(index), value: index, role: ComposeModifierRole.tag) + } : indexedContent! + context.indexedItems(indexRange, identifier, onDeleteAction, onMoveAction, factory) } else if let objects { - context.objectItems(objects, identifier!, onDeleteAction, onMoveAction, objectContent!) + let factory: (Any) -> View = context.isTagging ? { object in + let view = objectContent!(object) + guard let tag = identifier!(object) else { + return view + } + return TagModifierView(view: view, value: tag, role: ComposeModifierRole.tag) + } : objectContent! + context.objectItems(objects, identifier!, onDeleteAction, onMoveAction, factory) } else if let objectsBinding { - context.objectBindingItems(objectsBinding, identifier!, editActions, onDeleteAction, onMoveAction, objectsBindingContent!) + let factory: (Binding>, Int) -> View = context.isTagging ? { objects, index in + let view = objectsBindingContent!(objects, index) + guard let tag = identifier!(objects.wrappedValue[index]) else { + return view + } + return TagModifierView(view: view, value: tag, role: ComposeModifierRole.tag) + } : objectsBindingContent! + context.objectBindingItems(objectsBinding, identifier!, editActions, onDeleteAction, onMoveAction, factory) } } @Composable private func collectViews(from view: any View, context: ComposeContext) -> [View] { return (view as? ComposeBuilder)?.collectViews(context: context) ?? [view] } + + @Composable private func taggedViews(for views: [View], defaultTag: Any?, context: ComposeContext) -> [View] { + return views.map { view in + if let taggedView = TagModifierView.strip(from: view, role: ComposeModifierRole.tag) { + return taggedView + } else if let defaultTag { + return TagModifierView(view: view, value: defaultTag, role: ComposeModifierRole.tag) + } else { + return view + } + } + } #else public var body: some View { stubView() @@ -181,6 +246,9 @@ public final class ForEach : View, LazyItemFactory { } #if SKIP +/// Mark composers that should not unroll `ForEach` views. +protocol ForEachComposer { +} // Kotlin does not support generic constructor parameters, so we have to model many ForEach constructors as functions diff --git a/Sources/SkipUI/SkipUI/Containers/LazySupport.swift b/Sources/SkipUI/SkipUI/Containers/LazySupport.swift index fd58460e..0697008e 100644 --- a/Sources/SkipUI/SkipUI/Containers/LazySupport.swift +++ b/Sources/SkipUI/SkipUI/Containers/LazySupport.swift @@ -37,6 +37,7 @@ public final class LazyItemFactoryContext { /// Initialize the content factories. func initialize( + isTagging: Bool = false, startItemIndex: Int, item: (View) -> Void, indexedItems: (Range, ((Any) -> AnyHashable?)?, Int, ((IndexSet) -> Void)?, ((IndexSet, Int) -> Void)?, (Int) -> View) -> Void, @@ -45,6 +46,7 @@ public final class LazyItemFactoryContext { sectionHeader: (View) -> Void, sectionFooter: (View) -> Void ) { + self.isTagging = isTagging self.startItemIndex = startItemIndex content.removeAll() @@ -95,6 +97,9 @@ public final class LazyItemFactoryContext { } } + /// Whether we're in a tagging view placement. + private(set) var isTagging = false + /// The current number of content items. var count: Int { var itemCount = 0 @@ -291,7 +296,7 @@ public final class LazyItemFactoryContext { private var content: [Content] = [] } -final class LazyItemCollectingComposer: SideEffectComposer { +final class LazyItemCollectingComposer: SideEffectComposer, ForEachComposer { let views: MutableList = mutableListOf() // Use MutableList to avoid copies @Composable override func Compose(view: View, context: (Bool) -> ComposeContext) -> ComposeResult { diff --git a/Sources/SkipUI/SkipUI/Containers/List.swift b/Sources/SkipUI/SkipUI/Containers/List.swift index ab836070..21440d02 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -70,8 +70,9 @@ let listSectionnCornerRadius = 8.0 public final class List : View { let fixedContent: ComposeBuilder? let forEach: ForEach? + let itemTransformer: ((any View) -> any View)? - init(fixedContent: (any View)? = nil, identifier: ((Any) -> AnyHashable?)? = nil, indexRange: Range? = nil, indexedContent: ((Int) -> any View)? = nil, objects: (any RandomAccessCollection)? = nil, objectContent: ((Any) -> any View)? = nil, objectsBinding: Binding>? = nil, objectsBindingContent: ((Binding>, Int) -> any View)? = nil, editActions: EditActions = []) { + init(fixedContent: (any View)? = nil, identifier: ((Any) -> AnyHashable?)? = nil, itemTransformer: ((any View) -> any View)? = nil, indexRange: Range? = nil, indexedContent: ((Int) -> any View)? = nil, objects: (any RandomAccessCollection)? = nil, objectContent: ((Any) -> any View)? = nil, objectsBinding: Binding>? = nil, objectsBindingContent: ((Binding>, Int) -> any View)? = nil, editActions: EditActions = []) { if let fixedContent { self.fixedContent = fixedContent as? ComposeBuilder ?? ComposeBuilder(view: fixedContent) } else { @@ -86,6 +87,7 @@ public final class List : View { } else { self.forEach = nil } + self.itemTransformer = itemTransformer } public convenience init(@ViewBuilder content: () -> any View) { @@ -218,6 +220,7 @@ public final class List : View { forceUnanimatedItems.value = false } + let isTagging = EnvironmentValues.shared._placement.contains(ViewPlacement.tagged) LazyColumn(state: reorderableState.listState, modifier: modifier) { let sectionHeaderContext = context.content(composer: RenderingComposer { view, context in ComposeSectionHeader(view: view, context: context(false), styling: styling, isTop: false) @@ -242,6 +245,7 @@ public final class List : View { startItemIndex += 1 // Search field } factoryContext.value.initialize( + isTagging: isTagging, startItemIndex: startItemIndex, item: { view in item { @@ -383,7 +387,7 @@ public final class List : View { $0.set_placement(placement.union(ViewPlacement.listItem)) } in: { // Note that we're calling the same view's Compose function again with a new context - view.Compose(context: context.content(composer: ListItemComposer(contentModifier: Self.contentModifier))) + view.Compose(context: context.content(composer: ListItemComposer(contentModifier: Self.contentModifier, itemTransformer: itemTransformer))) } if itemModifierView?.separator != Visibility.hidden { Self.ComposeSeparator() @@ -680,17 +684,23 @@ protocol ListItemAdapting { #if SKIP final class ListItemComposer: RenderingComposer { let contentModifier: Modifier + let itemTransformer: ((View) -> View)? - init(contentModifier: Modifier) { + init(contentModifier: Modifier, itemTransformer: ((View) -> View)? = nil) { self.contentModifier = contentModifier + self.itemTransformer = itemTransformer } @Composable override func Compose(view: View, context: (Bool) -> ComposeContext) { + let view = itemTransformer?(view) ?? view if let listItemAdapting = view as? ListItemAdapting, listItemAdapting.shouldComposeListItem() { listItemAdapting.ComposeListItem(context: context(false), contentModifier: contentModifier) - } else if view is ComposeModifierView || !view.isSwiftUIModuleView { + } else if view is ComposeModifierView || view is ComposeBuilder || !view.isSwiftUIModuleView { // Allow content of modifier views and custom views to adapt to list item rendering - view.ComposeContent(context: context(true)) + var contentContext = context(true) + // Erase item transformer, as we've already applied it + contentContext.composer = ListItemComposer(contentModifier: contentModifier) + view.ComposeContent(context: contentContext) } else { Box(modifier: contentModifier, contentAlignment: androidx.compose.ui.Alignment.CenterStart) { view.ComposeContent(context: context(false)) diff --git a/Sources/SkipUI/SkipUI/Controls/Picker.swift b/Sources/SkipUI/SkipUI/Controls/Picker.swift index 10714e3b..d1878eed 100644 --- a/Sources/SkipUI/SkipUI/Controls/Picker.swift +++ b/Sources/SkipUI/SkipUI/Controls/Picker.swift @@ -54,34 +54,39 @@ public struct Picker : View, ListItemAdapting { #if SKIP @Composable override func ComposeContent(context: ComposeContext) { - let views = taggedViews(context: context) let style = EnvironmentValues.shared._pickerStyle ?? PickerStyle.automatic if style == PickerStyle.segmented { - ComposeSegmentedValue(views: views, context: context) + ComposeSegmentedValue(context: context) } else if EnvironmentValues.shared._labelsHidden || style != .navigationLink { // Most picker styles do not display their label outside of a Form (see ComposeListItem) - ComposeSelectedValue(views: views, context: context, style: style) + let (selectedView, tagViews) = processPickerContent(content: content, selection: selection, context: context) + ComposeSelectedValue(selectedView: selectedView, tagViews: tagViews, context: context, style: style) } else { // Navigation link style outside of a List. This style does display its label - let contentContext = context.content() - let navigator = LocalNavigator.current - let title = titleFromLabel(context: contentContext) - let modifier = context.modifier.clickable(onClick: { - navigator?.navigateToView(PickerSelectionView(views: views, selection: selection, title: title)) - }, enabled: EnvironmentValues.shared.isEnabled) - ComposeContainer(modifier: modifier, fillWidth: true) { modifier in - Row(modifier: modifier, verticalAlignment: androidx.compose.ui.Alignment.CenterVertically) { - Box(modifier: Modifier.padding(end: 8.dp).weight(Float(1.0))) { - Button.ComposeTextButton(label: label, context: contentContext) - } - ComposeSelectedValue(views: views, context: contentContext, style: style, performsAction: false) + ComposeLabeledValue(context: context, style: style) + } + } + + @Composable private func ComposeLabeledValue(context: ComposeContext, style: PickerStyle) { + let (selectedView, tagViews) = processPickerContent(content: content, selection: selection, context: context) + let contentContext = context.content() + let navigator = LocalNavigator.current + let title = titleFromLabel(context: contentContext) + let modifier = context.modifier.clickable(onClick: { + navigator?.navigateToView(PickerSelectionView(title: title, content: content, selection: selection)) + }, enabled: EnvironmentValues.shared.isEnabled) + ComposeContainer(modifier: modifier, fillWidth: true) { modifier in + Row(modifier: modifier, verticalAlignment: androidx.compose.ui.Alignment.CenterVertically) { + Box(modifier: Modifier.padding(end: 8.dp).weight(Float(1.0))) { + Button.ComposeTextButton(label: label, context: contentContext) } + ComposeSelectedValue(selectedView: selectedView, tagViews: tagViews, context: contentContext, style: style, performsAction: false) } } } - @Composable private func ComposeSelectedValue(views: [TagModifierView], context: ComposeContext, style: PickerStyle, performsAction: Bool = true) { - let selectedValueView = views.first { $0.value == selection.wrappedValue } ?? EmptyView() + @Composable private func ComposeSelectedValue(selectedView: View, tagViews: [TagModifierView]?, context: ComposeContext, style: PickerStyle, performsAction: Bool = true) { + let selectedValueView = selectedView ?? processPickerContent(content: content, selection: selection, context: context).0 let selectedValueLabel: View let isMenu: Bool if style == .automatic || style == .menu { @@ -99,7 +104,7 @@ public struct Picker : View, ListItemAdapting { Box { Button.ComposeTextButton(label: selectedValueLabel, context: context) { isMenuExpanded.value = !isMenuExpanded.value } if isMenu { - ComposePickerSelectionMenu(views: views, isExpanded: isMenuExpanded, context: context.content()) + ComposePickerSelectionMenu(tagViews: tagViews, isExpanded: isMenuExpanded, context: context.content()) } } } else { @@ -112,8 +117,9 @@ public struct Picker : View, ListItemAdapting { } // SKIP INSERT: @OptIn(ExperimentalMaterial3Api::class) - @Composable private func ComposeSegmentedValue(views: [TagModifierView], context: ComposeContext) { - let selectedIndex = views.firstIndex { $0.value == selection.wrappedValue } + @Composable private func ComposeSegmentedValue(context: ComposeContext) { + let (_, tagViews) = processPickerContent(content: content, selection: selection, context: context, requireTagViews: true) + let selectedIndex = tagViews?.firstIndex { $0.value == selection.wrappedValue } let isEnabled = EnvironmentValues.shared.isEnabled let colors: SegmentedButtonColors let disabledBorderColor = Color.primary.colorImpl().copy(alpha: ContentAlpha.disabled) @@ -124,14 +130,16 @@ public struct Picker : View, ListItemAdapting { } let contentContext = context.content() SingleChoiceSegmentedButtonRow(modifier: Modifier.fillWidth().then(context.modifier)) { - for (index, tagView) in views.enumerated() { - SegmentedButton(shape: SegmentedButtonDefaults.itemShape(index: index, count: views.count), colors: colors, selected: index == selectedIndex, enabled: isEnabled, onClick: { - selection.wrappedValue = tagView.value as! SelectionValue - }) { - if let label = tagView.view as? Label { - let _ = label.ComposeTitle(context: contentContext) - } else { - let _ = tagView.view.Compose(context: contentContext) + if let tagViews { + for (index, tagView) in tagViews.enumerated() { + SegmentedButton(shape: SegmentedButtonDefaults.itemShape(index: index, count: tagViews.count), colors: colors, selected: index == selectedIndex, enabled: isEnabled, onClick: { + selection.wrappedValue = tagView.value as! SelectionValue + }) { + if let label = tagView.view as? Label { + let _ = label.ComposeTitle(context: contentContext) + } else { + let _ = tagView.view.Compose(context: contentContext) + } } } } @@ -143,7 +151,7 @@ public struct Picker : View, ListItemAdapting { } @Composable func ComposeListItem(context: ComposeContext, contentModifier: Modifier) { - let views = taggedViews(context: context) + let (selectedView, tagViews) = processPickerContent(content: content, selection: selection, context: context) let style = EnvironmentValues.shared._pickerStyle ?? PickerStyle.automatic var isMenu = false let isMenuExpanded = remember { mutableStateOf(false) } @@ -151,7 +159,7 @@ public struct Picker : View, ListItemAdapting { if style == .navigationLink { let navigator = LocalNavigator.current let title = titleFromLabel(context: context) - onClick = { navigator?.navigateToView(PickerSelectionView(views: views, selection: selection, title: title)) } + onClick = { navigator?.navigateToView(PickerSelectionView(title: title, content: content, selection: selection)) } } else { isMenu = true onClick = { isMenuExpanded.value = !isMenuExpanded.value } @@ -164,9 +172,9 @@ public struct Picker : View, ListItemAdapting { } } Box { - ComposeSelectedValue(views: views, context: context, style: style, performsAction: false) + ComposeSelectedValue(selectedView: selectedView, tagViews: tagViews, context: context, style: style, performsAction: false) if isMenu { - ComposePickerSelectionMenu(views: views, isExpanded: isMenuExpanded, context: context) + ComposePickerSelectionMenu(tagViews: tagViews, isExpanded: isMenuExpanded, context: context) } } if style == .navigationLink { @@ -175,8 +183,9 @@ public struct Picker : View, ListItemAdapting { } } - @Composable private func ComposePickerSelectionMenu(views: [TagModifierView], isExpanded: MutableState, context: ComposeContext) { + @Composable private func ComposePickerSelectionMenu(tagViews: [TagModifierView]?, isExpanded: MutableState, context: ComposeContext) { // Create selectable views from the *content* of each tag view, preserving the enclosing tag + let views = tagViews ?? processPickerContent(content: content, selection: selection, context: context, requireTagViews: true).1 ?? [] let menuItems = views.map { tagView in let button = Button(action: { selection.wrappedValue = tagView.value as! SelectionValue @@ -194,16 +203,6 @@ public struct Picker : View, ListItemAdapting { } } - @Composable private func taggedViews(context: ComposeContext) -> [TagModifierView] { - var views: [TagModifierView] = [] - EnvironmentValues.shared.setValues { - $0.set_placement(ViewPlacement.tagged) - } in: { - views = content.collectViews(context: context).compactMap { TagModifierView.strip(from: $0, role: ComposeModifierRole.tag) } - } - return views - } - @Composable private func titleFromLabel(context: ComposeContext) -> Text { return label.collectViews(context: context).compactMap { $0.strippingModifiers(perform: { $0 as? Text }) }.first ?? Text(verbatim: String(describing: selection.wrappedValue)) } @@ -248,33 +247,90 @@ extension View { } #if SKIP +@Composable private func processPickerContent(content: ComposeBuilder, selection: Binding, context: ComposeContext, requireTagViews: Bool = false) -> (View, [TagModifierView]?) { + let selectedTag = selection.wrappedValue + let viewCollectingComposer = PickerViewCollectingComposer(selectedTag: selectedTag, requireTagViews: requireTagViews) + let viewCollector = context.content(composer: viewCollectingComposer) + + EnvironmentValues.shared.setValues { + $0.set_placement(ViewPlacement.tagged) + } in: { + content.Compose(viewCollector) + } + var selectedView = viewCollectingComposer.selectedView + + let tagViews: [TagModifierView]? + if let views = viewCollectingComposer.tagViews { + tagViews = Array(views, nocopy: true) + } else { + tagViews = nil + } + if selectedView == nil, let tagViews { + selectedView = tagViews.first { $0.value == selectedTag } + } + return (selectedView ?? EmptyView(), tagViews) +} + +final class PickerViewCollectingComposer : SideEffectComposer, ForEachComposer { + let selectedTag: SelectionValue + let requireTagViews: Bool + var selectedView: View? + var tagViews: MutableList? + private var isFirstView = true + + init(selectedTag: SelectionValue, requireTagViews: Bool) { + self.selectedTag = selectedTag + self.requireTagViews = requireTagViews + } + + @Composable override func Compose(view: View, context: (Bool) -> ComposeContext) -> ComposeResult { + let isFirstView = self.isFirstView + self.isFirstView = false + if let forEach = view as? ForEach { + if isFirstView, !requireTagViews, let selectedView = + forEach.untaggedView(forTag: selectedTag, context: context(false)) { + self.selectedView = selectedView + } else if selectedView == nil { + forEach.ComposeContent(context: context(true)) + } + } else if selectedView == nil, let taggedView = TagModifierView.strip(from: view, role: ComposeModifierRole.tag) { + if tagViews == nil { + tagViews = mutableListOf() + } + tagViews!.add(taggedView) + } + return ComposeResult.ok + } +} + struct PickerSelectionView : View { - let views: [TagModifierView] - let selection: Binding let title: Text + let content: View + let selection: Binding + @State private var selectionValue: SelectionValue @Environment(\.dismiss) private var dismiss - init(views: [TagModifierView], selection: Binding, title: Text) { - self.views = views - self.selection = selection + init(title: Text, content: View, selection: Binding) { self.title = title + self.content = content + self.selection = selection self._selectionValue = State(initialValue: selection.wrappedValue) } var body: some View { - List { - ForEach(0.. some View { - Button { - selection.wrappedValue = label.value as! SelectionValue - selectionValue = selection.wrappedValue // Update the checkmark in the UI while we dismiss + @ViewBuilder private func rowView(label: View) -> some View { + let labelValue = TagModifierView.strip(from: label, role: .tag)?.value as? SelectionValue + return Button { + if let labelValue { + selection.wrappedValue = labelValue + self.selectionValue = labelValue // Update the checkmark in the UI while we dismiss + } dismiss() } label: { HStack { @@ -286,7 +342,7 @@ struct PickerSelectionView : View { .frame(maxWidth: .infinity) Image(systemName: "checkmark") .foregroundStyle(EnvironmentValues.shared._tint ?? Color.accentColor) - .opacity(label.value == selection.wrappedValue ? 1.0 : 0.0) + .opacity(labelValue == selectionValue ? 1.0 : 0.0) } } .buttonStyle(ButtonStyle.plain)