diff --git a/CHANGELOG.md b/CHANGELOG.md index eabbb54ff..b2ccee0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [53.8.0] +- [Accessibility] Added `Mode` attached property. + ## [53.7.4] - Fix error causing index out of range in `TabBadgeService` when tabs have changed diff --git a/src/app/Components/AccessibilitySamples/VoiceOverSamples/GroupChildrenSamples.xaml b/src/app/Components/AccessibilitySamples/VoiceOverSamples/GroupChildrenSamples.xaml new file mode 100644 index 000000000..a8ad29be5 --- /dev/null +++ b/src/app/Components/AccessibilitySamples/VoiceOverSamples/GroupChildrenSamples.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/Components/AccessibilitySamples/VoiceOverSamples/GroupChildrenSamples.xaml.cs b/src/app/Components/AccessibilitySamples/VoiceOverSamples/GroupChildrenSamples.xaml.cs new file mode 100644 index 000000000..a135c7755 --- /dev/null +++ b/src/app/Components/AccessibilitySamples/VoiceOverSamples/GroupChildrenSamples.xaml.cs @@ -0,0 +1,9 @@ +namespace Components.AccessibilitySamples.VoiceOverSamples; + +public partial class GroupChildrenSamples : DIPS.Mobile.UI.Components.Pages.ContentPage +{ + public GroupChildrenSamples() + { + InitializeComponent(); + } +} diff --git a/src/app/Components/AccessibilitySamples/VoiceOverSamples/VoiceOverSamples.xaml b/src/app/Components/AccessibilitySamples/VoiceOverSamples/VoiceOverSamples.xaml new file mode 100644 index 000000000..eeca3a872 --- /dev/null +++ b/src/app/Components/AccessibilitySamples/VoiceOverSamples/VoiceOverSamples.xaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/src/app/Components/AccessibilitySamples/VoiceOverSamples/VoiceOverSamples.xaml.cs b/src/app/Components/AccessibilitySamples/VoiceOverSamples/VoiceOverSamples.xaml.cs new file mode 100644 index 000000000..e0615f68e --- /dev/null +++ b/src/app/Components/AccessibilitySamples/VoiceOverSamples/VoiceOverSamples.xaml.cs @@ -0,0 +1,9 @@ +namespace Components.AccessibilitySamples.VoiceOverSamples; + +public partial class VoiceOverSamples +{ + public VoiceOverSamples() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/app/Components/App.xaml.cs b/src/app/Components/App.xaml.cs index b8f5f3a99..78dcee867 100644 --- a/src/app/Components/App.xaml.cs +++ b/src/app/Components/App.xaml.cs @@ -1,90 +1,50 @@ using Components.Resources.LocalizedStrings; -using Components.Services; -using DIPS.Mobile.UI.API.Library; +using Enum = System.Enum; namespace Components; public partial class App { - private readonly AppCenterService m_appCenterService; - public App() { InitializeComponent(); - - m_appCenterService = new AppCenterService(); } protected override Window CreateWindow(IActivationState? activationState) { - var shell = new DIPS.Mobile.UI.Components.Shell.Shell - { - ShouldGarbageCollectPreviousPage = true, - - }; + var shell = new DIPS.Mobile.UI.Components.Shell.Shell(); + var allSamples = REGISTER_YOUR_SAMPLES_HERE.RegisterSamples(); var tabBar = new TabBar(); - var tab = new Tab(); - tab.Items.Add(new ShellContent + foreach (var sampleType in Enum.GetValues()) { - ContentTemplate = - new DataTemplate(() => new MainPage(new List {SampleType.Resources, SampleType.Components}.OrderBy(s => s.ToString()), - REGISTER_YOUR_SAMPLES_HERE.RegisterSamples())) - }); - tabBar.Items.Add(tab); - shell.Items.Add(tabBar); - - return new Window(shell); - } - - protected override void OnStart() - { - _ = TryGetLatestVersion(); - - base.OnStart(); - } - - - private async Task TryGetLatestVersion() - { -#if DEBUG - return true; -#endif - - var release = await m_appCenterService.GetLatestVersion(); - if (release != null) - { - var latestVersion = new Version(release.Version); - var currentVersion = AppInfo.Version; - if (currentVersion >= latestVersion) + var samples = allSamples.Where(s => s.Type == sampleType).OrderBy(s => s.Name); + var title = sampleType switch { - return false; - } - - if (Current?.MainPage == null) return true; - var wantToDownload = await Current.MainPage.DisplayAlert(LocalizedStrings.New_version, - LocalizedStrings.New_version_message, LocalizedStrings.Download, LocalizedStrings.Cancel); - if (!wantToDownload) + SampleType.Resources => LocalizedStrings.Resources, + SampleType.Components => LocalizedStrings.Components, + SampleType.Accessibility => LocalizedStrings.Accessibility, + _ => sampleType.ToString() + }; + + var tab = new Tab { - return false; - } - - await Launcher.OpenAsync(release.InstallUri); - return true; + Title = title, + Items = + { + new ShellContent + { + ContentTemplate = new DataTemplate(() => new SamplesPage(sampleType, samples)) + } + } + }; + + tabBar.Items.Add(tab); } + + shell.Items.Add(tabBar); - return false; - } - - protected override void OnResume() - { - _ = TryGetLatestVersion(); - base.OnResume(); - } - - protected override void OnSleep() - { - base.OnSleep(); + return new Window(shell); } } \ No newline at end of file diff --git a/src/app/Components/Components.csproj b/src/app/Components/Components.csproj index 3efe9954e..f244a8b6e 100644 --- a/src/app/Components/Components.csproj +++ b/src/app/Components/Components.csproj @@ -27,8 +27,8 @@ false + Apple Development Automatic - iPhone Developer false @@ -133,6 +133,10 @@ BarcodeScanningSample.xaml Code + + VoiceOverSamples.xaml + Code + @@ -163,6 +167,13 @@ Designer + + Designer + + + + + diff --git a/src/app/Components/MainPage.cs b/src/app/Components/MainPage.cs deleted file mode 100644 index 63cae5fbd..000000000 --- a/src/app/Components/MainPage.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Components.Resources.LocalizedStrings; -using DIPS.Mobile.UI.Components.ListItems; -using DIPS.Mobile.UI.Components.ListItems.Extensions; -using DIPS.Mobile.UI.Resources.Sizes; - -namespace Components; - -public class MainPage : DIPS.Mobile.UI.Components.Pages.ContentPage -{ - - public MainPage(IEnumerable sampleTypes, List samples) - { - Title = $"{AppInfo.Current.Name} ({AppInfo.Current.VersionString})"; - var collectionView = new DIPS.Mobile.UI.Components.Lists.CollectionView() - { - ItemsSource = sampleTypes, - ItemTemplate = new DataTemplate(() => new NavigateToSamplesItem(samples)), - Header = new Grid // Padding at the top - { - Padding = new Thickness(0, Sizes.GetSize(SizeName.page_margin_small)) - } - }; - - Content = collectionView; - - DIPS.Mobile.UI.Effects.Layout.Layout.SetAutoHideLastDivider(collectionView, true); - - } -} - -public class NavigateToSamplesItem : NavigationListItem -{ - private readonly List m_samples; - private SampleType m_sampleType; - - public NavigateToSamplesItem(List samples) - { - m_samples = samples; - Command = new Command(TryNavigateToSamplesPage); - HasBottomDivider = true; - } - - private void TryNavigateToSamplesPage() - { - var samples = m_samples.Where(sample => sample.Type == m_sampleType).ToList().OrderBy(sample => sample.Name); - if (!samples.Any()) - { - Shell.Current.DisplayAlert("No samples", - $"Theres no samples for {m_sampleType} yet.", "Ok"); - } - - Shell.Current.Navigation.PushAsync((new SamplesPage(m_sampleType, samples))); - } - - protected override void OnBindingContextChanged() - { - base.OnBindingContextChanged(); - if (BindingContext is SampleType sampleType) - { - m_sampleType = sampleType; - Title = sampleType switch - { - SampleType.Components => LocalizedStrings.Components, - SampleType.Resources => LocalizedStrings.Resources, - _ => "Unknown" - }; - } - } -} \ No newline at end of file diff --git a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs index 23aceeacd..d252fa844 100644 --- a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs +++ b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs @@ -1,3 +1,5 @@ +using Components.AccessibilitySamples; +using Components.AccessibilitySamples.VoiceOverSamples; using Components.ComponentsSamples.Alerting; using Components.ComponentsSamples.AmplitudeView; using Components.ComponentsSamples.BarcodeScanning; @@ -65,6 +67,7 @@ public static List RegisterSamples() new(SampleType.Components, "Tag", () => new TagsSamples()), new(SampleType.Components, "Counters", () => new CountersSamples()), new(SampleType.Components, "TabView", () => new TabViewSamples()), + new(SampleType.Accessibility, "VoiceOver/TalkBack", () => new VoiceOverSamples()), diff --git a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs index 90a43458c..176d7597d 100644 --- a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs +++ b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.Designer.cs @@ -710,5 +710,167 @@ internal static string Elements { return ResourceManager.GetString("Elements", resourceCulture); } } + + internal static string Accessibility { + get { + return ResourceManager.GetString("Accessibility", resourceCulture); + } + } + + internal static string VoiceOver { + get { + return ResourceManager.GetString("VoiceOver", resourceCulture); + } + } + + internal static string VoiceOver_Description { + get { + return ResourceManager.GetString("VoiceOver_Description", resourceCulture); + } + } + + internal static string VoiceOver_GroupChildren_Title { + get { + return ResourceManager.GetString("VoiceOver_GroupChildren_Title", resourceCulture); + } + } + + internal static string VoiceOver_GroupChildren_Description { + get { + return ResourceManager.GetString("VoiceOver_GroupChildren_Description", resourceCulture); + } + } + + internal static string VoiceOver_ExcludeChildren_Title { + get { + return ResourceManager.GetString("VoiceOver_ExcludeChildren_Title", resourceCulture); + } + } + + internal static string VoiceOver_ExcludeChildren_Description { + get { + return ResourceManager.GetString("VoiceOver_ExcludeChildren_Description", resourceCulture); + } + } + + internal static string VoiceOver_PatientCard_Name { + get { + return ResourceManager.GetString("VoiceOver_PatientCard_Name", resourceCulture); + } + } + + internal static string VoiceOver_PatientCard_Born { + get { + return ResourceManager.GetString("VoiceOver_PatientCard_Born", resourceCulture); + } + } + + internal static string VoiceOver_PatientCard_Phone { + get { + return ResourceManager.GetString("VoiceOver_PatientCard_Phone", resourceCulture); + } + } + + internal static string VoiceOver_PatientCard_PhoneNumber { + get { + return ResourceManager.GetString("VoiceOver_PatientCard_PhoneNumber", resourceCulture); + } + } + + internal static string VoiceOver_ProductCard_Name { + get { + return ResourceManager.GetString("VoiceOver_ProductCard_Name", resourceCulture); + } + } + + internal static string VoiceOver_ProductCard_Price { + get { + return ResourceManager.GetString("VoiceOver_ProductCard_Price", resourceCulture); + } + } + + internal static string VoiceOver_ProductCard_Stock { + get { + return ResourceManager.GetString("VoiceOver_ProductCard_Stock", resourceCulture); + } + } + + internal static string VoiceOver_ProductCard_Rating { + get { + return ResourceManager.GetString("VoiceOver_ProductCard_Rating", resourceCulture); + } + } + + internal static string VoiceOver_WithoutGrouping { + get { + return ResourceManager.GetString("VoiceOver_WithoutGrouping", resourceCulture); + } + } + + internal static string VoiceOver_WithGrouping { + get { + return ResourceManager.GetString("VoiceOver_WithGrouping", resourceCulture); + } + } + + internal static string VoiceOver_SwipeGestures { + get { + return ResourceManager.GetString("VoiceOver_SwipeGestures", resourceCulture); + } + } + + internal static string VoiceOver_Example { + get { + return ResourceManager.GetString("VoiceOver_Example", resourceCulture); + } + } + + internal static string VoiceOver_AddressCard_Street { + get { + return ResourceManager.GetString("VoiceOver_AddressCard_Street", resourceCulture); + } + } + + internal static string VoiceOver_AddressCard_City { + get { + return ResourceManager.GetString("VoiceOver_AddressCard_City", resourceCulture); + } + } + + internal static string VoiceOver_AddressCard_Country { + get { + return ResourceManager.GetString("VoiceOver_AddressCard_Country", resourceCulture); + } + } + + internal static string VoiceOver_ExcludeExample_Title { + get { + return ResourceManager.GetString("VoiceOver_ExcludeExample_Title", resourceCulture); + } + } + + internal static string VoiceOver_ExcludeExample_Description { + get { + return ResourceManager.GetString("VoiceOver_ExcludeExample_Description", resourceCulture); + } + } + + internal static string VoiceOver_WithoutExclude { + get { + return ResourceManager.GetString("VoiceOver_WithoutExclude", resourceCulture); + } + } + + internal static string VoiceOver_WithExclude { + get { + return ResourceManager.GetString("VoiceOver_WithExclude", resourceCulture); + } + } + + internal static string VoiceOver_CustomSemanticDescription { + get { + return ResourceManager.GetString("VoiceOver_CustomSemanticDescription", resourceCulture); + } + } } } diff --git a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx index 5b3e9c73d..2f6a1f316 100644 --- a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx +++ b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.en.resx @@ -351,4 +351,85 @@ elements + + Accessibility + + + VoiceOver + + + These examples demonstrate how to use the Accessibility.Mode attached property to improve VoiceOver/TalkBack experience. + + + GroupChildren Mode + + + Groups all children of a container into a single accessibility element. Screen readers will read all content in one focus gesture instead of requiring navigation to each child individually. + + + ExcludeChildren Mode + + + Excludes all children from the accessibility tree. Useful when you have decorative elements or complex layouts that should not be read individually. + + + John Doe + + + Born: 1980-05-15 + + + Phone: + + + +47 123 45 678 + + + Premium Product + + + Price: $49.99 + + + In stock: 15 units + + + Rating: ⭐⭐⭐⭐⭐ + + + Without grouping (Default) + + + With GroupChildren + + + swipe gesture(s) required + + + Example + + + 123 Main Street + + + New York, NY 10001 + + + United States + + + Product card with decorative elements + + + The decorative border around the icon is excluded from accessibility, so screen readers only focus on meaningful content. + + + Without ExcludeChildren + + + With ExcludeChildren and custom description + + + Premium product available in store + \ No newline at end of file diff --git a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx index 9c9b0ac25..e9bf76140 100644 --- a/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx +++ b/src/app/Components/Resources/LocalizedStrings/LocalizedStrings.resx @@ -356,4 +356,85 @@ elementer + + Tilgjengelighet + + + VoiceOver + + + Disse eksemplene viser hvordan du bruker Accessibility.Mode attached property for å forbedre VoiceOver/TalkBack opplevelsen. + + + GroupChildren Modus + + + Grupperer alle barn i en container til et enkelt tilgjengelighetselement. Skjermlesere vil lese alt innhold med én fokusgest i stedet for å kreve navigering til hvert barn individuelt. + + + ExcludeChildren Modus + + + Ekskluderer alle barn fra tilgjengelighetstreet. Nyttig når du har dekorative elementer eller komplekse layouter som ikke skal leses individuelt. + + + Kari Nordmann + + + Født: 15.05.1980 + + + Telefon: + + + +47 123 45 678 + + + Premium Produkt + + + Pris: 499 kr + + + På lager: 15 stk + + + Vurdering: ⭐⭐⭐⭐⭐ + + + Uten gruppering (Standard) + + + Med GroupChildren + + + sveipegest(er) kreves + + + Eksempel + + + Storgata 123 + + + 0123 Oslo + + + Norge + + + Produktkort med dekorative elementer + + + Den dekorative rammen rundt ikonet er ekskludert fra tilgjengeligheten, slik at skjermlesere kun fokuserer på det meningsfulle innholdet. + + + Uten ExcludeChildren + + + Med ExcludeChildren og egendefinert beskrivelse + + + Premium produkt tilgjengelig i butikk + \ No newline at end of file diff --git a/src/app/Components/SampleType.cs b/src/app/Components/SampleType.cs index 7e3493042..99c0d3c94 100644 --- a/src/app/Components/SampleType.cs +++ b/src/app/Components/SampleType.cs @@ -2,6 +2,7 @@ namespace Components; public enum SampleType { + Components, Resources, - Components + Accessibility } \ No newline at end of file diff --git a/src/app/Components/Services/AppCenterService.cs b/src/app/Components/Services/AppCenterService.cs deleted file mode 100644 index 8b9389865..000000000 --- a/src/app/Components/Services/AppCenterService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Newtonsoft.Json; - -namespace Components.Services -{ - internal class AppCenterService : IAppCenterService - { - private readonly HttpClient m_httpClient; - - public AppCenterService() - { - m_httpClient = new HttpClient(); - } - - public async Task> RetrieveAndSetAppConfig() - { - await using var fileStream = await FileSystem.Current.OpenAppPackageFileAsync("appconfig.json"); - using var reader = new StreamReader(fileStream); - - var json = await reader.ReadToEndAsync(); - var anonymous = new - { - AppCenter = new - { - ApiKey = "", - AppName = "", - DistributionGroup = "" - } - }; - var rawConfig = JsonConvert.DeserializeAnonymousType(json, anonymous); - if (!m_httpClient.DefaultRequestHeaders.Contains("X-API-Token")) - { - m_httpClient.DefaultRequestHeaders.Add("X-API-Token", rawConfig.AppCenter.ApiKey); - } - - if (m_httpClient.BaseAddress == null) - { - m_httpClient.BaseAddress = new Uri("https://api.appcenter.ms/v0.1/apps/dips-as/"); - } - - return new Tuple(rawConfig.AppCenter.AppName, rawConfig.AppCenter.DistributionGroup); - } - - - public async Task GetLatestVersion() - { - var (appName, distributionGroupName) = await RetrieveAndSetAppConfig(); - var response = await m_httpClient.GetAsync( - $"{appName}/distribution_groups/{distributionGroupName}/releases/latest"); - response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); - var anon = new {Id = 0, short_version = "", uploaded_at = DateTime.Now, install_url="", download_url= ""}; - var release = JsonConvert.DeserializeAnonymousType(json, anon); - var installUrl = release.install_url; -#if __ANDROID__ - installUrl = release.download_url; -#endif - return release != null ? new Release(release.Id, release.short_version, release.uploaded_at, new Uri(installUrl)) : null; - } - } - - internal interface IAppCenterService - { - Task GetLatestVersion(); - } -} \ No newline at end of file diff --git a/src/app/Components/Services/Release.cs b/src/app/Components/Services/Release.cs deleted file mode 100644 index a46d1794f..000000000 --- a/src/app/Components/Services/Release.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Components.Services -{ - internal class Release - { - public Release(int id, string version, DateTime uploadedAt, Uri installUri) - { - Id = id; - Version = version; - UploadedAt = uploadedAt; - InstallUri = installUri; - } - - public int Id { get; } - public string Version { get; } - public DateTime UploadedAt { get; } - public Uri InstallUri { get; } - } -} \ No newline at end of file diff --git a/src/app/Playground/VetleSamples/VetlePage.xaml b/src/app/Playground/VetleSamples/VetlePage.xaml index 0a2716c34..8d04d8c5b 100644 --- a/src/app/Playground/VetleSamples/VetlePage.xaml +++ b/src/app/Playground/VetleSamples/VetlePage.xaml @@ -25,10 +25,11 @@ - + - + + diff --git a/src/library/DIPS.Mobile.UI/AssemblyInfo.cs b/src/library/DIPS.Mobile.UI/AssemblyInfo.cs index f79b1e5a8..f833c5b15 100644 --- a/src/library/DIPS.Mobile.UI/AssemblyInfo.cs +++ b/src/library/DIPS.Mobile.UI/AssemblyInfo.cs @@ -97,6 +97,7 @@ [assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Components.Tag")] [assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Components.Counters")] [assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Components.Loading.DelayedView")] +[assembly: XmlnsDefinition("http://dips.com/mobile.ui","DIPS.Mobile.UI.Effects.Accessibility")] diff --git a/src/library/DIPS.Mobile.UI/Effects/Accessibility/Accessibility.Properties.cs b/src/library/DIPS.Mobile.UI/Effects/Accessibility/Accessibility.Properties.cs new file mode 100644 index 000000000..a65845ff6 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Effects/Accessibility/Accessibility.Properties.cs @@ -0,0 +1,57 @@ +namespace DIPS.Mobile.UI.Effects.Accessibility; + +public partial class Accessibility +{ + public static readonly BindableProperty ModeProperty = BindableProperty.CreateAttached("Mode", + typeof(Mode), + typeof(Accessibility), + Mode.None, + propertyChanged: OnModeChanged); +} + +public enum Mode +{ + None = 0, + /// + /// Groups all children of a container into a single accessibility element.
+ /// For more information, see GroupChildren Mode + ///
+ /// + /// Note: This does not take into account if the hierarchy changes in runtime, or if any texts changes after the layout has been rendered + ///
+ /// + /// This effect combines the text content of all descendant elements into the parent container's accessibility description, + /// allowing screen readers to read all content in a single focus gesture instead of requiring users to navigate each child separately. + /// + /// When to use: + /// + /// Multiple child labels or text elements that form a single logical piece of information (e.g., address blocks, contact information, product details) + /// Read-only informational displays where all content should be announced together + /// Card-like UI patterns where related information should be grouped + /// When you want to reduce the number of swipe gestures needed for screen reader users + /// + /// When NOT to use: + /// + /// When any child element is interactive (Button, Entry, Switch, etc.) - interactive elements need individual focus for usability + /// When child elements represent separate, unrelated pieces of information that users might want to navigate individually + /// When children have complex accessibility requirements or need custom hints/traits + /// In lists or collections where each item should be individually navigable + /// + ///
+ /// + /// + /// <VerticalStackLayout dui:Accessibility.Mode="GroupChildren"> + /// <Label Text="John Doe" /> + /// <Label Text="Born: 1980-05-15" /> + /// <HorizontalStackLayout> + /// <Label Text="Phone:" /> + /// <Label Text="+47 123 45 678" /> + /// </HorizontalStackLayout> + /// <Label Text="john.doe@example.com" /> + /// </VerticalStackLayout> + /// <!-- VoiceOver/TalkBack will read: "John Doe, Born: 1980-05-15, Phone:, +47 123 45 678, john.doe@example.com" --> + /// + /// Without this mode, screen readers would require 5 separate swipe gestures to read all information. With GroupChildren, it's read in one focus. + /// + GroupChildren = 1 +} \ No newline at end of file diff --git a/src/library/DIPS.Mobile.UI/Effects/Accessibility/Accessibility.cs b/src/library/DIPS.Mobile.UI/Effects/Accessibility/Accessibility.cs new file mode 100644 index 000000000..c08040498 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Effects/Accessibility/Accessibility.cs @@ -0,0 +1,41 @@ +using DIPS.Mobile.UI.Effects.Accessibility.Effects; + +namespace DIPS.Mobile.UI.Effects.Accessibility; + +/// +/// Provides accessibility features for views.
+/// For more information, see Accessibility in DIPS.Mobile.UI. +///
+public partial class Accessibility +{ + public static Mode GetMode(BindableObject view) + { + return (Mode)view.GetValue(ModeProperty); + } + + /// + /// Attached property that can simplify common accessibility scenarios for screen readers.
+ /// For more information, see DIPS.Mobile.UI Accessibility Mode. + ///
+ public static void SetMode(BindableObject view, Mode mode) + { + view.SetValue(ModeProperty, mode); + } + + private static void OnModeChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is not View view || newValue is not Mode mode) + return; + + switch (mode) + { + case Mode.GroupChildren: + view.Behaviors.Add(new GroupEffect()); + break; + case Mode.None: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } +} \ No newline at end of file diff --git a/src/library/DIPS.Mobile.UI/Effects/Accessibility/Effects/GroupEffect.cs b/src/library/DIPS.Mobile.UI/Effects/Accessibility/Effects/GroupEffect.cs new file mode 100644 index 000000000..5f41fcdde --- /dev/null +++ b/src/library/DIPS.Mobile.UI/Effects/Accessibility/Effects/GroupEffect.cs @@ -0,0 +1,96 @@ +using DIPS.Mobile.UI.Internal.Logging; + +namespace DIPS.Mobile.UI.Effects.Accessibility.Effects; + +internal class GroupEffect : Behavior +{ + private Microsoft.Maui.Controls.Layout? m_layout; + + protected override void OnAttachedTo(BindableObject bindable) + { + base.OnAttachedTo(bindable); + + if (bindable is not Microsoft.Maui.Controls.Layout layout) + { + DUILogService.LogError($"{bindable.GetType().Name} is not a Microsoft.Maui.Controls.Layout"); + return; + } + + m_layout = layout; + m_layout.HandlerChanged += OnHandlerChanged; + } + + private void OnHandlerChanged(object? sender, EventArgs e) + { + if (m_layout?.Handler is null) + return; + + UpdateGroupedDescription(); + } + + private void UpdateGroupedDescription() + { + if (m_layout == null) + return; + + var combinedText = GetAllDescendants(m_layout) + .Select(ExtractTextFromElement) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToList(); + + var description = string.Join(", ", combinedText); + SemanticProperties.SetDescription(m_layout, description); + } + + private static IEnumerable GetAllDescendants(IView element) + { + if (element is not Microsoft.Maui.Controls.Layout layout) + yield break; + + foreach (var child in layout.Children) + { + yield return child; + + foreach (var descendant in GetAllDescendants(child)) + { + yield return descendant; + } + } + } + + private static string? ExtractTextFromElement(IView element) + { + if (element is BindableObject bindableObject) + { + var semanticDescription = SemanticProperties.GetDescription(bindableObject); + if (!string.IsNullOrEmpty(semanticDescription)) + { + return semanticDescription; + } + } + + if (element is Label label) + { + return GetLabelText(label); + } + + return string.Empty; + } + + private static string? GetLabelText(Label label) + { + return label.FormattedText != null ? label.FormattedText.ToString() : label.Text; + } + + protected override void OnDetachingFrom(BindableObject bindable) + { + base.OnDetachingFrom(bindable); + + if (m_layout is not null) + { + m_layout.HandlerChanged -= OnHandlerChanged; + } + + m_layout = null; + } +}