Skip to content

Add Accessibility Selected for iOS CollectionView #29014

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public bool IsSelected
SetSelectionStates(_isSelected);

ItemView.Activated = _isSelected;
ItemView.Selected = _isSelected;
OnSelectedChanged();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal void SelectItem(object selectedItem)
CollectionView.PerformBatchUpdates(null, _ =>
{
CollectionView.SelectItem(index, true, UICollectionViewScrollPosition.None);
CollectionView.CellForItem(index)?.UpdateSelectedAccessibility(true);
});
}
}
Expand Down Expand Up @@ -75,6 +76,8 @@ void FormsSelectItem(NSIndexPath indexPath)
ItemsView.SelectedItems.Add(GetItemAtIndex(indexPath));
break;
}

CollectionView.CellForItem(indexPath)?.UpdateSelectedAccessibility(true);
}

void FormsDeselectItem(NSIndexPath indexPath)
Expand All @@ -91,6 +94,8 @@ void FormsDeselectItem(NSIndexPath indexPath)
ItemsView.SelectedItems.Remove(GetItemAtIndex(indexPath));
break;
}

CollectionView.CellForItem(indexPath)?.UpdateSelectedAccessibility(false);
}

internal void UpdatePlatformSelection()
Expand Down Expand Up @@ -130,6 +135,10 @@ internal void UpdateSelectionMode()
{
var mode = ItemsView.SelectionMode;

// We want to make sure we clear the selection trait before we switch modes.
// If we do this after we switch modes, cells that are selected may not show up as selected anymore.
CollectionView.ClearSelectedAccessibilityTraits(CollectionView.GetIndexPathsForSelectedItems());

switch (mode)
{
case SelectionMode.None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal void SelectItem(object selectedItem)
CollectionView.PerformBatchUpdates(null, _ =>
{
CollectionView.SelectItem(index, true, UICollectionViewScrollPosition.None);
CollectionView.CellForItem(index)?.UpdateSelectedAccessibility(true);
});
}
}
Expand Down Expand Up @@ -75,6 +76,8 @@ void FormsSelectItem(NSIndexPath indexPath)
ItemsView.SelectedItems.Add(GetItemAtIndex(indexPath));
break;
}

CollectionView.CellForItem(indexPath)?.UpdateSelectedAccessibility(true);
}

void FormsDeselectItem(NSIndexPath indexPath)
Expand All @@ -91,6 +94,8 @@ void FormsDeselectItem(NSIndexPath indexPath)
ItemsView.SelectedItems.Remove(GetItemAtIndex(indexPath));
break;
}

CollectionView.CellForItem(indexPath)?.UpdateSelectedAccessibility(false);
}

internal void UpdatePlatformSelection()
Expand Down Expand Up @@ -130,6 +135,10 @@ internal void UpdateSelectionMode()
{
var mode = ItemsView.SelectionMode;

// We want to make sure we clear the selection trait before we switch modes.
// If we do this after we switch modes, cells that are selected may not show up as selected anymore.
CollectionView.ClearSelectedAccessibilityTraits(CollectionView.GetIndexPathsForSelectedItems());

switch (mode)
{
case SelectionMode.None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ namespace Microsoft.Maui.Controls.Platform;

internal static class AcessibilityExtensions
{
internal static void UpdateSelectedAccessibility(this UICollectionViewCell cell, bool selected)
{
// Catalyst and iOS Simulators applies/removes the 'Selected' trait to the cell automatically.
// iOS Devices do not apply the 'Selected' trait automatically to the cell unless VoiceOver is on.
// On iOS, the 'Selected' trait needs to be applied to the first child of the cell for VoiceOver to announce it.
#if IOS
if (cell.ContentView is not null && cell.ContentView.Subviews.Length > 0)
{
var firstChild = cell.ContentView.Subviews[0];

if (selected)
{
firstChild.AccessibilityTraits |= UIAccessibilityTrait.Selected;
}
else
{
firstChild.AccessibilityTraits &= ~UIAccessibilityTrait.Selected;
}
}
#endif
}

internal static void ClearSelectedAccessibilityTraits(this UICollectionView collectionView, Foundation.NSIndexPath[] indices)
{
// Catalyst and iOS Simulators applies/removes the 'Selected' trait to the cell automatically.
// iOS Devices do not apply the 'Selected' trait automatically to the cell unless VoiceOver is on.
// On iOS, the 'Selected' trait needs to be applied to the first child of the cell for VoiceOver to announce it.
#if IOS
foreach (var index in indices)
{
var cell = collectionView.CellForItem(index);
if (cell?.ContentView is not null && cell.ContentView.Subviews.Length > 0)
{
var firstChild = cell.ContentView.Subviews[0];
firstChild.AccessibilityTraits &= ~UIAccessibilityTrait.Selected;
}
}
#endif
}

internal static void UpdateAccessibilityTraits(this UICollectionView collectionView, SelectableItemsView itemsView)
{
foreach (var subview in collectionView.Subviews)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue21375.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Maui.Controls.Sample.Issues.Issue21375"
Title="Issue21375">
<VerticalStackLayout>
<CollectionView ItemsSource="{Binding Items}" SelectionMode="Single" x:Name="collectionView" AutomationId="collectionView" Background="LightBlue">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout Padding="10" SemanticProperties.Description="{Binding Name}" HeightRequest="50">
<Label Text="{Binding Name}" FontAttributes="Bold" FontSize="16"/>
<Label Text="{Binding Description}" FontSize="14"/>
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Label Margin="0,10,0,0" Text="SelectionMode" />
<Button Text="None" Clicked="NoneSelectionMode" x:Name="noneButton" AutomationId="noneButton"/>
<Button Text="Single" Clicked="SingleSelectionMode" AutomationId="singleButton"/>
<Button Text="Multiple" Clicked="MultipleSelectionMode" AutomationId="multipleButton" />
<Button Text="Calculate" Clicked="Calculate" x:Name="calculateButton" AutomationId="calculateButton"/>
<Editor x:Name="Output" HeightRequest="400"/>
</VerticalStackLayout>
</ContentPage>
104 changes: 104 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue21375.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Collections.ObjectModel;
using System.Text;

#if ANDROID
using AndroidX.RecyclerView.Widget;
#endif

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 21375, "Selected CollectionView item is not announced", PlatformAffected.iOS & PlatformAffected.macOS & PlatformAffected.Android)]
public partial class Issue21375 : ContentPage
{
public ObservableCollection<Item> Items { get; set; }

public Issue21375()
{
InitializeComponent();

Items = new ObservableCollection<Item>
{
new Item { Name = "Item 1", Description = "Description for item 1" },
new Item { Name = "Item 2", Description = "Description for item 2" },
new Item { Name = "Item 3", Description = "Description for item 3" },
new Item { Name = "Item 4", Description = "Description for item 4" },
new Item { Name = "Item 5", Description = "Description for item 5" }
};

BindingContext = this;
}

void NoneSelectionMode(object sender, EventArgs e)
{
collectionView.SelectionMode = SelectionMode.None;
}

void SingleSelectionMode(object sender, EventArgs e)
{
collectionView.SelectionMode = SelectionMode.Single;
}

void MultipleSelectionMode(object sender, EventArgs e)
{
collectionView.SelectionMode = SelectionMode.Multiple;
}

void Calculate(object sender, EventArgs e)
{
var sb = new StringBuilder();
#if IOS || MACCATALYST
if (collectionView.Handler?.PlatformView is UIKit.UIView vc
&& vc.Subviews is UIKit.UIView[] subviews && subviews.Length > 0
&& subviews[0] is UIKit.UICollectionView uiCollectionView)
{
for (int i = 0; i < uiCollectionView.VisibleCells.Length; i++)
{
var cell = uiCollectionView.VisibleCells[i];
sb.AppendLine($"Item{i} Cell: {cell.AccessibilityTraits}");
if (cell.ContentView is not null && cell.ContentView.Subviews.Length > 0)
{
var firstChild = cell.ContentView.Subviews[0];
sb.AppendLine($"Item{i} FirstChild: {firstChild.AccessibilityTraits}");
}
}
}
#elif ANDROID
var plat = collectionView.Handler?.PlatformView;
if (plat is RecyclerView recyclerView)
{
var adapter = recyclerView.GetAdapter();
var layoutManager = recyclerView.GetLayoutManager();
var childCount = layoutManager.ChildCount;
for (int i = 0; i < childCount; i++)
{
var viewHolder = recyclerView.GetChildAt(i);
if (viewHolder is null)
continue;

var position = recyclerView.GetChildAdapterPosition(viewHolder);
sb.AppendLine($"Item{position} Cell: {viewHolder.Selected}");
}
}
#elif WINDOWS
var plat = collectionView.Handler?.PlatformView;
if (plat is Microsoft.UI.Xaml.Controls.ListView listView)
{
foreach (var item in listView.Items)
{
var container = listView.ContainerFromItem(item) as Microsoft.UI.Xaml.Controls.ListViewItem;
if (container != null)
{
sb.AppendLine($"Item: {item} IsSelected: {container.IsSelected}");
}
}
}
#endif
Output.Text = sb.ToString();
}

public class Item
{
public string Name { get; set; }
public string Description { get; set; }
}
}
24 changes: 24 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue21375_2.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Maui.Controls.Sample"
x:Class="Maui.Controls.Sample.Issues.Issue21375_2">
<VerticalStackLayout>
<local:CollectionView2 ItemsSource="{Binding Items}" SelectionMode="Single" x:Name="collectionView" AutomationId="collectionView" Background="LightBlue">
<CollectionView.ItemTemplate>
<DataTemplate>
<StackLayout Padding="10" SemanticProperties.Description="{Binding Name}" HeightRequest="50">
<Label Text="{Binding Name}" FontAttributes="Bold" FontSize="16"/>
<Label Text="{Binding Description}" FontSize="14"/>
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</local:CollectionView2>
<Label Margin="0,10,0,0" Text="SelectionMode" />
<Button Text="None" Clicked="NoneSelectionMode" x:Name="noneButton" AutomationId="noneButton"/>
<Button Text="Single" Clicked="SingleSelectionMode" AutomationId="singleButton"/>
<Button Text="Multiple" Clicked="MultipleSelectionMode" AutomationId="multipleButton" />
<Button Text="Calculate" Clicked="Calculate" x:Name="calculateButton" AutomationId="calculateButton"/>
<Editor x:Name="Output" HeightRequest="400"/>
</VerticalStackLayout>
</ContentPage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Collections.ObjectModel;
using System.Text;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 21375_2, "Selected CollectionView2 item is not announced", PlatformAffected.iOS & PlatformAffected.macOS)]
public partial class Issue21375_2 : ContentPage
{
public ObservableCollection<Item> Items { get; set; }

public Issue21375_2()
{
InitializeComponent();

Items = new ObservableCollection<Item>
{
new Item { Name = "Item 1", Description = "Description for item 1" },
new Item { Name = "Item 2", Description = "Description for item 2" },
new Item { Name = "Item 3", Description = "Description for item 3" },
new Item { Name = "Item 4", Description = "Description for item 4" },
new Item { Name = "Item 5", Description = "Description for item 5" }
};

BindingContext = this;
}

void NoneSelectionMode(object sender, EventArgs e)
{
collectionView.SelectionMode = SelectionMode.None;
}

void SingleSelectionMode(object sender, EventArgs e)
{
collectionView.SelectionMode = SelectionMode.Single;
}

void MultipleSelectionMode(object sender, EventArgs e)
{
collectionView.SelectionMode = SelectionMode.Multiple;
}

void Calculate(object sender, EventArgs e)
{
var sb = new StringBuilder();
#if IOS || MACCATALYST
if (collectionView.Handler?.PlatformView is UIKit.UIView vc
&& vc.Subviews is UIKit.UIView[] subviews && subviews.Length > 0
&& subviews[0] is UIKit.UICollectionView uiCollectionView)
{
for (int i = 0; i < uiCollectionView.VisibleCells.Length; i++)
{
var cell = uiCollectionView.VisibleCells[i];
sb.AppendLine($"Item{i} Cell: {cell.AccessibilityTraits}");
if (cell.ContentView is not null && cell.ContentView.Subviews.Length > 0)
{
var firstChild = cell.ContentView.Subviews[0];
sb.AppendLine($"Item{i} FirstChild: {firstChild.AccessibilityTraits}");
}
}
}
#endif
Output.Text = sb.ToString();
}

public class Item
{
public string Name { get; set; }
public string Description { get; set; }
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading