diff --git a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj index 20ca0576d4..1a68c4d732 100644 --- a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj +++ b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj @@ -16,7 +16,7 @@ Resources\Resource.Designer.cs Off False - v10.0 + v11.0 Properties\AndroidManifest.xml diff --git a/samples/ControlCatalog.Android/Properties/AndroidManifest.xml b/samples/ControlCatalog.Android/Properties/AndroidManifest.xml index 02e97f3065..9effda7e79 100644 --- a/samples/ControlCatalog.Android/Properties/AndroidManifest.xml +++ b/samples/ControlCatalog.Android/Properties/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/src/Android/Avalonia.Android/ActivityTracker.cs b/src/Android/Avalonia.Android/ActivityTracker.cs deleted file mode 100644 index 2ad1d9e361..0000000000 --- a/src/Android/Avalonia.Android/ActivityTracker.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Android.App; -using Android.OS; - -namespace Avalonia.Android -{ - internal class ActivityTracker : Java.Lang.Object, global::Android.App.Application.IActivityLifecycleCallbacks - { - public static Activity Current { get; private set; } - public void OnActivityCreated(Activity activity, Bundle savedInstanceState) - { - Current = activity; - } - - public void OnActivityDestroyed(Activity activity) - { - if (Current == activity) - Current = null; - } - - public void OnActivityPaused(Activity activity) - { - if (Current == activity) - Current = null; - } - - public void OnActivityResumed(Activity activity) - { - Current = activity; - } - - public void OnActivitySaveInstanceState(Activity activity, Bundle outState) - { - Current = activity; - } - - public void OnActivityStarted(Activity activity) - { - Current = activity; - } - - public void OnActivityStopped(Activity activity) - { - if (Current == activity) - Current = null; - } - } -} \ No newline at end of file diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs index e0ceb0c8b7..e0ed9b1fda 100644 --- a/src/Android/Avalonia.Android/AndroidPlatform.cs +++ b/src/Android/Avalonia.Android/AndroidPlatform.cs @@ -32,6 +32,7 @@ namespace Avalonia.Android class AndroidPlatform : IPlatformSettings, IWindowingPlatform { public static readonly AndroidPlatform Instance = new AndroidPlatform(); + public static AndroidPlatformOptions Options { get; private set; } public Size DoubleClickSize => new Size(4, 4); public TimeSpan DoubleClickTime => TimeSpan.FromMilliseconds(200); public double RenderScalingFactor => _scalingFactor; @@ -46,23 +47,23 @@ namespace Avalonia.Android public static void Initialize(Type appType, AndroidPlatformOptions options) { + Options = options; + AvaloniaLocator.CurrentMutable .Bind().ToTransient() - .Bind().ToTransient() + .Bind().ToTransient() .Bind().ToSingleton() .Bind().ToConstant(Instance) .Bind().ToConstant(new AndroidThreadingInterface()) .Bind().ToTransient() .Bind().ToConstant(Instance) .Bind().ToSingleton() - .Bind().ToConstant(new DefaultRenderTimer(60)) + .Bind().ToConstant(new ChoreographerTimer()) .Bind().ToConstant(new RenderLoop()) .Bind().ToSingleton() .Bind().ToConstant(new AssetLoader(appType.Assembly)); SkiaPlatform.Initialize(); - ((global::Android.App.Application) global::Android.App.Application.Context.ApplicationContext) - .RegisterActivityLifecycleCallbacks(new ActivityTracker()); if (options.UseGpu) { @@ -83,6 +84,7 @@ namespace Avalonia.Android public sealed class AndroidPlatformOptions { + public bool UseDeferredRendering { get; set; } = true; public bool UseGpu { get; set; } = true; } } diff --git a/src/Android/Avalonia.Android/Avalonia.Android.csproj b/src/Android/Avalonia.Android/Avalonia.Android.csproj index c170e8449c..8c6775733f 100644 --- a/src/Android/Avalonia.Android/Avalonia.Android.csproj +++ b/src/Android/Avalonia.Android/Avalonia.Android.csproj @@ -1,6 +1,6 @@  - monoandroid90 + monoandroid11.0 true diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs index d3696aa41d..52b68f8e2f 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -13,9 +13,8 @@ namespace Avalonia.Android protected override void OnCreate(Bundle savedInstanceState) { - RequestWindowFeature(WindowFeatures.NoTitle); View = new AvaloniaView(this); - if(_content != null) + if (_content != null) View.Content = _content; SetContentView(View); TakeKeyEvents(true); diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index 72732a1f95..8de3657283 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -1,11 +1,12 @@ using System; using Android.Content; +using Android.Runtime; using Android.Views; using Android.Widget; using Avalonia.Android.Platform.SkiaPlatform; using Avalonia.Controls; using Avalonia.Controls.Embedding; -using Avalonia.Platform; +using Avalonia.Rendering; namespace Avalonia.Android { @@ -14,6 +15,8 @@ namespace Avalonia.Android private readonly EmbeddableControlRoot _root; private readonly ViewImpl _view; + private IDisposable? _timerSubscription; + public AvaloniaView(Context context) : base(context) { _view = new ViewImpl(context); @@ -33,6 +36,36 @@ namespace Avalonia.Android return _view.View.DispatchKeyEvent(e); } + public override void OnVisibilityAggregated(bool isVisible) + { + base.OnVisibilityAggregated(isVisible); + OnVisibilityChanged(isVisible); + } + + protected override void OnVisibilityChanged(View changedView, [GeneratedEnum] ViewStates visibility) + { + base.OnVisibilityChanged(changedView, visibility); + OnVisibilityChanged(visibility == ViewStates.Visible); + } + + private void OnVisibilityChanged(bool isVisible) + { + if (isVisible) + { + if (AvaloniaLocator.Current.GetService() is ChoreographerTimer timer) + { + _timerSubscription = timer.SubscribeView(this); + } + + _root.Renderer.Start(); + } + else + { + _root.Renderer.Stop(); + _timerSubscription?.Dispose(); + } + } + class ViewImpl : TopLevelImpl { public ViewImpl(Context context) : base(context) diff --git a/src/Android/Avalonia.Android/ChoreographerTimer.cs b/src/Android/Avalonia.Android/ChoreographerTimer.cs new file mode 100644 index 0000000000..1d898261a3 --- /dev/null +++ b/src/Android/Avalonia.Android/ChoreographerTimer.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Disposables; +using System.Threading.Tasks; + +using Android.OS; +using Android.Views; + +using Avalonia.Rendering; + +using Java.Lang; + +namespace Avalonia.Android +{ + internal sealed class ChoreographerTimer : Java.Lang.Object, IRenderTimer, Choreographer.IFrameCallback + { + private readonly object _lock = new object(); + + private readonly Thread _thread; + private readonly TaskCompletionSource _choreographer = new TaskCompletionSource(); + + private readonly ISet _views = new HashSet(); + + private Action _tick; + private int _count; + + public ChoreographerTimer() + { + _thread = new Thread(Loop); + _thread.Start(); + } + + public event Action Tick + { + add + { + lock (_lock) + { + _tick += value; + _count++; + + if (_count == 1) + { + _choreographer.Task.Result.PostFrameCallback(this); + } + } + } + remove + { + lock (_lock) + { + _tick -= value; + _count--; + } + } + } + + internal IDisposable SubscribeView(AvaloniaView view) + { + lock (_lock) + { + _views.Add(view); + + if (_views.Count == 1) + { + _choreographer.Task.Result.PostFrameCallback(this); + } + } + + return Disposable.Create( + () => + { + lock (_lock) + { + _views.Remove(view); + } + } + ); + } + + private void Loop() + { + Looper.Prepare(); + _choreographer.SetResult(Choreographer.Instance); + Looper.Loop(); + } + + public void DoFrame(long frameTimeNanos) + { + _tick?.Invoke(TimeSpan.FromTicks(frameTimeNanos / 100)); + + lock (_lock) + { + if (_count > 0 && _views.Count > 0) + { + Choreographer.Instance.PostFrameCallback(this); + } + } + } + } +} diff --git a/src/Android/Avalonia.Android/CursorFactory.cs b/src/Android/Avalonia.Android/CursorFactory.cs index 9eb28c67f9..6293637d4e 100644 --- a/src/Android/Avalonia.Android/CursorFactory.cs +++ b/src/Android/Avalonia.Android/CursorFactory.cs @@ -1,12 +1,21 @@ -using System; using Avalonia.Input; using Avalonia.Platform; namespace Avalonia.Android { - internal class CursorFactory : IStandardCursorFactory + internal class CursorFactory : ICursorFactory { - public IPlatformHandle GetCursor(StandardCursorType cursorType) - => new PlatformHandle(IntPtr.Zero, "ZeroCursor"); + public ICursorImpl CreateCursor(IBitmapImpl cursor, PixelPoint hotSpot) => CursorImpl.ZeroCursor; + + public ICursorImpl GetCursor(StandardCursorType cursorType) => CursorImpl.ZeroCursor; + + private sealed class CursorImpl : ICursorImpl + { + public static CursorImpl ZeroCursor { get; } = new CursorImpl(); + + private CursorImpl() { } + + public void Dispose() { } + } } } diff --git a/src/Android/Avalonia.Android/OpenGL/GlPlatformSurface.cs b/src/Android/Avalonia.Android/OpenGL/GlPlatformSurface.cs index 4f4c03fe77..a9710039f8 100644 --- a/src/Android/Avalonia.Android/OpenGL/GlPlatformSurface.cs +++ b/src/Android/Avalonia.Android/OpenGL/GlPlatformSurface.cs @@ -1,6 +1,4 @@ -using System.Linq; - -using Avalonia.OpenGL.Egl; +using Avalonia.OpenGL.Egl; using Avalonia.OpenGL.Surfaces; namespace Avalonia.Android.OpenGL @@ -17,7 +15,7 @@ namespace Avalonia.Android.OpenGL } public override IGlPlatformSurfaceRenderTarget CreateGlRenderTarget() => - new GlRenderTarget(_egl, _info, _egl.CreateWindowSurface(_info.Handle)); + new GlRenderTarget(_egl, _info, _egl.CreateWindowSurface(_info.Handle), _info.Handle); public static GlPlatformSurface TryCreate(IEglWindowGlPlatformSurfaceInfo info) { diff --git a/src/Android/Avalonia.Android/OpenGL/GlRenderTarget.cs b/src/Android/Avalonia.Android/OpenGL/GlRenderTarget.cs index 75bbd15e3e..f9071d9b27 100644 --- a/src/Android/Avalonia.Android/OpenGL/GlRenderTarget.cs +++ b/src/Android/Avalonia.Android/OpenGL/GlRenderTarget.cs @@ -1,23 +1,30 @@ -using Avalonia.OpenGL.Egl; +using System; + +using Avalonia.OpenGL.Egl; using Avalonia.OpenGL.Surfaces; namespace Avalonia.Android.OpenGL { - internal sealed class GlRenderTarget : EglPlatformSurfaceRenderTargetBase + internal sealed class GlRenderTarget : EglPlatformSurfaceRenderTargetBase, IGlPlatformSurfaceRenderTargetWithCorruptionInfo { private readonly EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo _info; private readonly EglSurface _surface; + private readonly IntPtr _handle; public GlRenderTarget( EglPlatformOpenGlInterface egl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo info, - EglSurface surface) + EglSurface surface, + IntPtr handle) : base(egl) { _info = info; _surface = surface; + _handle = handle; } + public bool IsCorrupted => _handle != _info.Handle; + public override IGlPlatformSurfaceRenderingSession BeginDraw() => BeginDraw(_surface, _info); } } diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index a8c7f7af9b..10bf414f25 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -98,10 +98,10 @@ namespace Avalonia.Android.Platform.SkiaPlatform public IEnumerable Surfaces => new object[] { _gl, _framebuffer }; - public IRenderer CreateRenderer(IRenderRoot root) - { - return new ImmediateRenderer(root); - } + public IRenderer CreateRenderer(IRenderRoot root) => + AndroidPlatform.Options.UseDeferredRendering + ? new DeferredRenderer(root, AvaloniaLocator.Current.GetService()) { RenderOnlyOnRenderThread = true } + : new ImmediateRenderer(root); public virtual void Hide() { @@ -123,7 +123,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform return PixelPoint.FromPoint(point, 1); } - public void SetCursor(IPlatformHandle cursor) + public void SetCursor(ICursorImpl cursor) { //still not implemented } diff --git a/src/Android/Avalonia.AndroidTestApplication/Avalonia.AndroidTestApplication.csproj b/src/Android/Avalonia.AndroidTestApplication/Avalonia.AndroidTestApplication.csproj index 4f49f3a863..9104f1618c 100644 --- a/src/Android/Avalonia.AndroidTestApplication/Avalonia.AndroidTestApplication.csproj +++ b/src/Android/Avalonia.AndroidTestApplication/Avalonia.AndroidTestApplication.csproj @@ -16,7 +16,7 @@ Resources\Resource.Designer.cs Off False - v10.0 + v11.0 Properties\AndroidManifest.xml @@ -150,4 +150,4 @@ - \ No newline at end of file + diff --git a/src/Android/Avalonia.AndroidTestApplication/Properties/AndroidManifest.xml b/src/Android/Avalonia.AndroidTestApplication/Properties/AndroidManifest.xml index e8e81da9de..57ee503005 100644 --- a/src/Android/Avalonia.AndroidTestApplication/Properties/AndroidManifest.xml +++ b/src/Android/Avalonia.AndroidTestApplication/Properties/AndroidManifest.xml @@ -1,6 +1,6 @@  - + \ No newline at end of file diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index b59fd7abde..aab6a41890 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -483,7 +483,9 @@ namespace Avalonia.Controls AvaloniaProperty.RegisterDirect( nameof(SelectedItem), o => o.SelectedItem, - (o, v) => o.SelectedItem = v); + (o, v) => o.SelectedItem = v, + defaultBindingMode: BindingMode.TwoWay, + enableDataValidation: true); /// /// Identifies the @@ -1333,7 +1335,7 @@ namespace Avalonia.Controls base.OnApplyTemplate(e); } - + /// /// Called to update the validation state for properties for which data validation is /// enabled. @@ -1342,7 +1344,7 @@ namespace Avalonia.Controls /// The new binding value for the property. protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) { - if (property == TextProperty) + if (property == TextProperty || property == SelectedItemProperty) { DataValidationErrors.SetError(this, value.Error); } diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs index b6b71644a3..abfbc038eb 100644 --- a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs +++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs @@ -91,14 +91,14 @@ namespace Avalonia.Controls /// public static readonly DirectProperty TextProperty = AvaloniaProperty.RegisterDirect(nameof(Text), o => o.Text, (o, v) => o.Text = v, - defaultBindingMode: BindingMode.TwoWay); + defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); /// /// Defines the property. /// public static readonly DirectProperty ValueProperty = AvaloniaProperty.RegisterDirect(nameof(Value), updown => updown.Value, - (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay); + (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true); /// /// Defines the property. @@ -370,6 +370,20 @@ namespace Avalonia.Controls } } + /// + /// Called to update the validation state for properties for which data validation is + /// enabled. + /// + /// The property. + /// The new binding value for the property. + protected override void UpdateDataValidation(AvaloniaProperty property, BindingValue value) + { + if (property == TextProperty || property == ValueProperty) + { + DataValidationErrors.SetError(this, value.Error); + } + } + /// /// Called when the property value changed. /// diff --git a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs index 3e78e951e2..b346fca330 100644 --- a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs @@ -14,6 +14,8 @@ using Avalonia.UnitTests; using Moq; using Xunit; using System.Collections.ObjectModel; +using System.Reactive.Linq; +using System.Reactive.Subjects; namespace Avalonia.Controls.UnitTests { @@ -396,6 +398,36 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(control.Text, control.ItemSelector(input, selectedItem)); }); } + + [Fact] + public void Text_Validation() + { + RunTest((control, textbox) => + { + var exception = new InvalidCastException("failed validation"); + var textObservable = new BehaviorSubject(new BindingNotification(exception, BindingErrorType.DataValidationError)); + control.Bind(AutoCompleteBox.TextProperty, textObservable); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(DataValidationErrors.GetHasErrors(control), true); + Assert.Equal(DataValidationErrors.GetErrors(control).SequenceEqual(new[] { exception }), true); + }); + } + + [Fact] + public void SelectedItem_Validation() + { + RunTest((control, textbox) => + { + var exception = new InvalidCastException("failed validation"); + var itemObservable = new BehaviorSubject(new BindingNotification(exception, BindingErrorType.DataValidationError)); + control.Bind(AutoCompleteBox.SelectedItemProperty, itemObservable); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(DataValidationErrors.GetHasErrors(control), true); + Assert.Equal(DataValidationErrors.GetErrors(control).SequenceEqual(new[] { exception }), true); + }); + } /// /// Retrieves a defined predicate filter through a new AutoCompleteBox diff --git a/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs b/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs new file mode 100644 index 0000000000..4cef7e4d05 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using System.Reactive.Subjects; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class NumericUpDownTests + { + private static TestServices Services => TestServices.StyledWindow; + + [Fact] + public void Text_Validation() + { + RunTest((control, textbox) => + { + var exception = new InvalidCastException("failed validation"); + var textObservable = new BehaviorSubject(new BindingNotification(exception, BindingErrorType.DataValidationError)); + control.Bind(NumericUpDown.TextProperty, textObservable); + Dispatcher.UIThread.RunJobs(); + + Assert.True(DataValidationErrors.GetHasErrors(control)); + Assert.True(DataValidationErrors.GetErrors(control).SequenceEqual(new[] { exception })); + }); + } + + [Fact] + public void Value_Validation() + { + RunTest((control, textbox) => + { + var exception = new InvalidCastException("failed validation"); + var valueObservable = new BehaviorSubject(new BindingNotification(exception, BindingErrorType.DataValidationError)); + control.Bind(NumericUpDown.ValueProperty, valueObservable); + Dispatcher.UIThread.RunJobs(); + + Assert.True(DataValidationErrors.GetHasErrors(control)); + Assert.True(DataValidationErrors.GetErrors(control).SequenceEqual(new[] { exception })); + }); + } + + private void RunTest(Action test) + { + using (UnitTestApplication.Start(Services)) + { + var control = CreateControl(); + TextBox textBox = GetTextBox(control); + var window = new Window { Content = control }; + window.ApplyTemplate(); + window.Presenter.ApplyTemplate(); + Dispatcher.UIThread.RunJobs(); + test.Invoke(control, textBox); + } + } + + private NumericUpDown CreateControl() + { + var control = new NumericUpDown + { + Template = CreateTemplate() + }; + + control.ApplyTemplate(); + return control; + } + private TextBox GetTextBox(NumericUpDown control) + { + return control.GetTemplateChildren() + .OfType() + .Select(b => b.Content) + .OfType() + .First(); + } + private IControlTemplate CreateTemplate() + { + return new FuncControlTemplate((control, scope) => + { + var textBox = + new TextBox + { + Name = "PART_TextBox" + }.RegisterInNameScope(scope); + return new ButtonSpinner + { + Name = "PART_Spinner", + Content = textBox, + }.RegisterInNameScope(scope); + }); + } + } +}