Browse Source

Merge branch 'master' into remove-mono-nukebuild

Steven Kirk 4 years ago
parent
commit
927f819b14
34 changed files with 475 additions and 247 deletions
  1. 3 2
      native/Avalonia.Native/src/OSX/menu.h
  2. 26 0
      native/Avalonia.Native/src/OSX/menu.mm
  3. 5 2
      native/Avalonia.Native/src/OSX/window.mm
  4. 1 1
      samples/ControlCatalog.Android/ControlCatalog.Android.csproj
  5. 1 1
      samples/ControlCatalog.Android/Properties/AndroidManifest.xml
  6. 0 47
      src/Android/Avalonia.Android/ActivityTracker.cs
  7. 7 25
      src/Android/Avalonia.Android/AndroidPlatform.cs
  8. 8 7
      src/Android/Avalonia.Android/AndroidThreadingInterface.cs
  9. 1 1
      src/Android/Avalonia.Android/Avalonia.Android.csproj
  10. 3 8
      src/Android/Avalonia.Android/AvaloniaActivity.cs
  11. 34 1
      src/Android/Avalonia.Android/AvaloniaView.cs
  12. 101 0
      src/Android/Avalonia.Android/ChoreographerTimer.cs
  13. 13 4
      src/Android/Avalonia.Android/CursorFactory.cs
  14. 2 4
      src/Android/Avalonia.Android/OpenGL/GlPlatformSurface.cs
  15. 10 3
      src/Android/Avalonia.Android/OpenGL/GlRenderTarget.cs
  16. 0 14
      src/Android/Avalonia.Android/Platform/Input/AndroidMouseDevice.cs
  17. 4 2
      src/Android/Avalonia.Android/Platform/SkiaPlatform/AndroidFramebuffer.cs
  18. 1 1
      src/Android/Avalonia.Android/Platform/SkiaPlatform/FramebufferManager.cs
  19. 2 0
      src/Android/Avalonia.Android/Platform/SkiaPlatform/InvalidationAwareSurfaceView.cs
  20. 18 30
      src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
  21. 22 69
      src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidTouchEventsHelper.cs
  22. 0 11
      src/Android/Avalonia.Android/app.config
  23. 2 2
      src/Android/Avalonia.AndroidTestApplication/Avalonia.AndroidTestApplication.csproj
  24. 1 1
      src/Android/Avalonia.AndroidTestApplication/Properties/AndroidManifest.xml
  25. 3 1
      src/Avalonia.Controls/ApiCompatBaseline.txt
  26. 5 3
      src/Avalonia.Controls/AutoCompleteBox.cs
  27. 2 0
      src/Avalonia.Controls/INativeMenuExporterEventsImplBridge.cs
  28. 32 1
      src/Avalonia.Controls/NativeMenu.cs
  29. 16 2
      src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs
  30. 1 1
      src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs
  31. 22 0
      src/Avalonia.Native/IAvnMenu.cs
  32. 2 3
      src/Avalonia.Native/avn.idl
  33. 32 0
      tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs
  34. 95 0
      tests/Avalonia.Controls.UnitTests/NumericUpDownTests.cs

+ 3 - 2
native/Avalonia.Native/src/OSX/menu.h

@@ -60,7 +60,6 @@ public:
     void RaiseOnClicked();
 };
 
-
 class AvnAppMenu : public ComSingleObject<IAvnMenu, &IID_IAvnMenu>
 {
 private:
@@ -71,10 +70,12 @@ public:
     FORWARD_IUNKNOWN()
     
     AvnAppMenu(IAvnMenuEvents* events);
-        
+
     AvnMenu* GetNative();
     
     void RaiseNeedsUpdate ();
+    void RaiseOpening();
+    void RaiseClosed();
     
     virtual HRESULT InsertItem (int index, IAvnMenuItem* item) override;
     

+ 26 - 0
native/Avalonia.Native/src/OSX/menu.mm

@@ -298,6 +298,23 @@ void AvnAppMenu::RaiseNeedsUpdate()
     }
 }
 
+void AvnAppMenu::RaiseOpening()
+{
+    if(_baseEvents != nullptr)
+    {
+        _baseEvents->Opening();
+    }
+}
+
+void AvnAppMenu::RaiseClosed()
+{
+    if(_baseEvents != nullptr)
+    {
+        _baseEvents->Closed();
+    }
+}
+
+
 HRESULT AvnAppMenu::InsertItem(int index, IAvnMenuItem *item)
 {
     @autoreleasepool
@@ -382,6 +399,15 @@ HRESULT AvnAppMenu::Clear()
     _parent->RaiseNeedsUpdate();
 }
 
+- (void)menuWillOpen:(NSMenu *)menu
+{
+    _parent->RaiseOpening();
+}
+
+- (void)menuDidClose:(NSMenu *)menu
+{
+    _parent->RaiseClosed();
+}
 
 @end
 

+ 5 - 2
native/Avalonia.Native/src/OSX/window.mm

@@ -2231,9 +2231,12 @@ protected:
     {
         @autoreleasepool
         {
-            [Window setContentSize:NSSize{x, y}];
+            if (Window != nullptr)
+            {
+                [Window setContentSize:NSSize{x, y}];
             
-            [Window setFrameTopLeftPoint:ToNSPoint(ConvertPointY(lastPositionSet))];
+                [Window setFrameTopLeftPoint:ToNSPoint(ConvertPointY(lastPositionSet))];
+            }
             
             return S_OK;
         }

+ 1 - 1
samples/ControlCatalog.Android/ControlCatalog.Android.csproj

@@ -16,7 +16,7 @@
     <AndroidResgenFile>Resources\Resource.Designer.cs</AndroidResgenFile>
     <GenerateSerializationAssemblies>Off</GenerateSerializationAssemblies>
     <AndroidUseLatestPlatformSdk>False</AndroidUseLatestPlatformSdk>
-    <TargetFrameworkVersion>v10.0</TargetFrameworkVersion>
+    <TargetFrameworkVersion>v11.0</TargetFrameworkVersion>
     <AndroidManifest>Properties\AndroidManifest.xml</AndroidManifest>
   </PropertyGroup>
   <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">

+ 1 - 1
samples/ControlCatalog.Android/Properties/AndroidManifest.xml

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="ControlCatalog.Android" android:versionCode="1" android:versionName="1.0" android:installLocation="auto">
-	<uses-sdk android:targetSdkVersion="29" />
+	<uses-sdk android:targetSdkVersion="30" />
 	<application android:label="ControlCatalog.Android"></application>
 </manifest>

+ 0 - 47
src/Android/Avalonia.Android/ActivityTracker.cs

@@ -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;
-        }
-    }
-}

+ 7 - 25
src/Android/Avalonia.Android/AndroidPlatform.cs

@@ -29,60 +29,42 @@ namespace Avalonia
 
 namespace Avalonia.Android
 {
-    class AndroidPlatform : IPlatformSettings, IWindowingPlatform
+    class AndroidPlatform : IPlatformSettings
     {
         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;
-        public double LayoutScalingFactor => _scalingFactor;
-
-        private readonly double _scalingFactor = 1;
-
-        public AndroidPlatform()
-        {
-            _scalingFactor = global::Android.App.Application.Context.Resources.DisplayMetrics.ScaledDensity;
-        }
 
         public static void Initialize(Type appType, AndroidPlatformOptions options)
         {
+            Options = options;
+
             AvaloniaLocator.CurrentMutable
                 .Bind<IClipboard>().ToTransient<ClipboardImpl>()
-                .Bind<IStandardCursorFactory>().ToTransient<CursorFactory>()
+                .Bind<ICursorFactory>().ToTransient<CursorFactory>()
                 .Bind<IKeyboardDevice>().ToSingleton<AndroidKeyboardDevice>()
                 .Bind<IPlatformSettings>().ToConstant(Instance)
                 .Bind<IPlatformThreadingInterface>().ToConstant(new AndroidThreadingInterface())
                 .Bind<ISystemDialogImpl>().ToTransient<SystemDialogImpl>()
-                .Bind<IWindowingPlatform>().ToConstant(Instance)
                 .Bind<IPlatformIconLoader>().ToSingleton<PlatformIconLoader>()
-                .Bind<IRenderTimer>().ToConstant(new DefaultRenderTimer(60))
+                .Bind<IRenderTimer>().ToConstant(new ChoreographerTimer())
                 .Bind<IRenderLoop>().ToConstant(new RenderLoop())
                 .Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>()
                 .Bind<IAssetLoader>().ToConstant(new AssetLoader(appType.Assembly));
 
             SkiaPlatform.Initialize();
-            ((global::Android.App.Application) global::Android.App.Application.Context.ApplicationContext)
-                .RegisterActivityLifecycleCallbacks(new ActivityTracker());
 
             if (options.UseGpu)
             {
                 EglPlatformOpenGlInterface.TryInitialize();
             }
         }
-
-        public IWindowImpl CreateWindow()
-        {
-            throw new NotSupportedException();
-        }
-
-        public IWindowImpl CreateEmbeddableWindow()
-        {
-            throw new NotSupportedException();
-        }
     }
 
     public sealed class AndroidPlatformOptions
     {
+        public bool UseDeferredRendering { get; set; } = true;
         public bool UseGpu { get; set; } = true;
     }
 }

+ 8 - 7
src/Android/Avalonia.Android/AndroidThreadingInterface.cs

@@ -1,25 +1,26 @@
 using System;
 using System.Reactive.Disposables;
 using System.Threading;
+
 using Android.OS;
+
 using Avalonia.Platform;
 using Avalonia.Threading;
 
+using App = Android.App.Application;
+
 namespace Avalonia.Android
 {
-    class AndroidThreadingInterface : IPlatformThreadingInterface
+    internal sealed class AndroidThreadingInterface : IPlatformThreadingInterface
     {
         private Handler _handler;
 
         public AndroidThreadingInterface()
         {
-            _handler = new Handler(global::Android.App.Application.Context.MainLooper);
+            _handler = new Handler(App.Context.MainLooper);
         }
 
-        public void RunLoop(CancellationToken cancellationToken)
-        {
-            return;
-        }
+        public void RunLoop(CancellationToken cancellationToken) => throw new NotSupportedException();
 
         public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick)
         {
@@ -57,7 +58,7 @@ namespace Avalonia.Android
                     });
                 }
             }, null, TimeSpan.Zero, interval);
-            
+
             return Disposable.Create(() =>
             {
                 lock (l)

+ 1 - 1
src/Android/Avalonia.Android/Avalonia.Android.csproj

@@ -1,6 +1,6 @@
 <Project Sdk="MSBuild.Sdk.Extras">
   <PropertyGroup>
-    <TargetFramework>monoandroid90</TargetFramework>
+    <TargetFramework>monoandroid11.0</TargetFramework>
     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
   </PropertyGroup>
   <ItemGroup>

+ 3 - 8
src/Android/Avalonia.Android/AvaloniaActivity.cs

@@ -1,4 +1,3 @@
-
 using Android.App;
 using Android.OS;
 using Android.Views;
@@ -7,15 +6,13 @@ namespace Avalonia.Android
 {
     public abstract class AvaloniaActivity : Activity
     {
-        
         internal AvaloniaView View;
         object _content;
 
         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);
@@ -36,9 +33,7 @@ namespace Avalonia.Android
             }
         }
 
-        public override bool DispatchKeyEvent(KeyEvent e)
-        {
-            return View.DispatchKeyEvent(e);
-        }
+        public override bool DispatchKeyEvent(KeyEvent e) =>
+            View.DispatchKeyEvent(e) ? true : base.DispatchKeyEvent(e);
     }
 }

+ 34 - 1
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<IRenderTimer>() 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)

+ 101 - 0
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> _choreographer = new TaskCompletionSource<Choreographer>();
+
+        private readonly ISet<AvaloniaView> _views = new HashSet<AvaloniaView>();
+
+        private Action<TimeSpan> _tick;
+        private int _count;
+
+        public ChoreographerTimer()
+        {
+            _thread = new Thread(Loop);
+            _thread.Start();
+        }
+
+        public event Action<TimeSpan> 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);
+                }
+            }
+        }
+    }
+}

+ 13 - 4
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() { }
+        }
     }
 }

+ 2 - 4
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)
         {

+ 10 - 3
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);
     }
 }

+ 0 - 14
src/Android/Avalonia.Android/Platform/Input/AndroidMouseDevice.cs

@@ -1,14 +0,0 @@
-using Avalonia.Input;
-
-namespace Avalonia.Android.Platform.Input
-{
-    public class AndroidMouseDevice : MouseDevice
-    {
-        public static AndroidMouseDevice Instance { get; } = new AndroidMouseDevice();
-
-        public AndroidMouseDevice()
-        {
-
-        }
-    }
-}

+ 4 - 2
src/Android/Avalonia.Android/Platform/SkiaPlatform/AndroidFramebuffer.cs

@@ -10,7 +10,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
     {
         private IntPtr _window;
 
-        public AndroidFramebuffer(Surface surface)
+        public AndroidFramebuffer(Surface surface, double scaling)
         {
             if(surface == null)
                 throw new ArgumentNullException(nameof(surface));
@@ -31,6 +31,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform
 
             RowBytes = buffer.stride * (Format == PixelFormat.Rgb565 ? 2 : 4);
             Address = buffer.bits;
+
+            Dpi = scaling * new Vector(96, 96);
         }
 
         public void Dispose()
@@ -44,7 +46,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
         public IntPtr Address { get; set; }
         public PixelSize Size { get; }
         public int RowBytes { get; }
-        public Vector Dpi { get; } = new Vector(96, 96);
+        public Vector Dpi { get; }
         public PixelFormat Format { get; }
 
         [DllImport("android")]

+ 1 - 1
src/Android/Avalonia.Android/Platform/SkiaPlatform/FramebufferManager.cs

@@ -12,6 +12,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform
             _topLevel = topLevel;
         }
 
-        public ILockedFramebuffer Lock() => new AndroidFramebuffer(_topLevel.InternalView.Holder.Surface);
+        public ILockedFramebuffer Lock() => new AndroidFramebuffer(_topLevel.InternalView.Holder.Surface, _topLevel.RenderScaling);
     }
 }

+ 2 - 0
src/Android/Avalonia.Android/Platform/SkiaPlatform/InvalidationAwareSurfaceView.cs

@@ -43,11 +43,13 @@ namespace Avalonia.Android
             }
         }
 
+        [Obsolete("deprecated")]
         public override void Invalidate(global::Android.Graphics.Rect dirty)
         {
             Invalidate();
         }
 
+        [Obsolete("deprecated")]
         public override void Invalidate(int l, int t, int r, int b)
         {
             Invalidate();

+ 18 - 30
src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs

@@ -6,7 +6,6 @@ using Android.Runtime;
 using Android.Views;
 
 using Avalonia.Android.OpenGL;
-using Avalonia.Android.Platform.Input;
 using Avalonia.Android.Platform.Specific;
 using Avalonia.Android.Platform.Specific.Helpers;
 using Avalonia.Controls;
@@ -35,16 +34,16 @@ namespace Avalonia.Android.Platform.SkiaPlatform
             _view = new ViewImpl(context, this, placeOnTop);
             _keyboardHelper = new AndroidKeyboardEventsHelper<TopLevelImpl>(this);
             _touchHelper = new AndroidTouchEventsHelper<TopLevelImpl>(this, () => InputRoot,
-                p => GetAvaloniaPointFromEvent(p));
+                GetAvaloniaPointFromEvent);
 
             _gl = GlPlatformSurface.TryCreate(this);
             _framebuffer = new FramebufferManager(this);
 
-            MaxClientSize = new Size(_view.Resources.DisplayMetrics.WidthPixels,
-                _view.Resources.DisplayMetrics.HeightPixels);
-        }
-
+            RenderScaling = (int)_view.Resources.DisplayMetrics.Density;
 
+            MaxClientSize = new PixelSize(_view.Resources.DisplayMetrics.WidthPixels,
+                _view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling);
+        }
 
         private bool _handleEvents;
 
@@ -58,25 +57,14 @@ namespace Avalonia.Android.Platform.SkiaPlatform
             }
         }
 
-        public virtual Point GetAvaloniaPointFromEvent(MotionEvent e) => new Point(e.GetX(), e.GetY());
+        public virtual Point GetAvaloniaPointFromEvent(MotionEvent e, int pointerIndex) =>
+            new Point(e.GetX(pointerIndex), e.GetY(pointerIndex)) / RenderScaling;
 
         public IInputRoot InputRoot { get; private set; }
 
-        public virtual Size ClientSize
-        {
-            get
-            {
-                if (_view == null)
-                    return new Size(0, 0);
-                return new Size(_view.Width, _view.Height);
-            }
-            set
-            {
-
-            }
-        }
+        public virtual Size ClientSize => Size.ToSize(RenderScaling);
 
-        public IMouseDevice MouseDevice => AndroidMouseDevice.Instance;
+        public IMouseDevice MouseDevice { get; } = new MouseDevice();
 
         public Action Closed { get; set; }
 
@@ -98,10 +86,10 @@ namespace Avalonia.Android.Platform.SkiaPlatform
 
         public IEnumerable<object> 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<IRenderLoop>()) { RenderOnlyOnRenderThread = true }
+            : new ImmediateRenderer(root);
 
         public virtual void Hide()
         {
@@ -115,15 +103,15 @@ namespace Avalonia.Android.Platform.SkiaPlatform
 
         public Point PointToClient(PixelPoint point)
         {
-            return point.ToPoint(1);
+            return point.ToPoint(RenderScaling);
         }
 
         public PixelPoint PointToScreen(Point point)
         {
-            return PixelPoint.FromPoint(point, 1);
+            return PixelPoint.FromPoint(point, RenderScaling);
         }
 
-        public void SetCursor(IPlatformHandle cursor)
+        public void SetCursor(ICursorImpl cursor)
         {
             //still not implemented
         }
@@ -138,7 +126,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
             _view.Visibility = ViewStates.Visible;
         }
 
-        public double RenderScaling => 1;
+        public double RenderScaling { get; }
 
         void Draw()
         {
@@ -193,7 +181,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
 
             void ISurfaceHolderCallback.SurfaceChanged(ISurfaceHolder holder, Format format, int width, int height)
             {
-                var newSize = new Size(width, height);
+                var newSize = new PixelSize(width, height).ToSize(_tl.RenderScaling);
 
                 if (newSize != _oldSize)
                 {

+ 22 - 69
src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidTouchEventsHelper.cs

@@ -11,7 +11,7 @@ namespace Avalonia.Android.Platform.Specific.Helpers
         private TView _view;
         public bool HandleEvents { get; set; }
 
-        public AndroidTouchEventsHelper(TView view, Func<IInputRoot> getInputRoot, Func<MotionEvent, Point> getPointfunc)
+        public AndroidTouchEventsHelper(TView view, Func<IInputRoot> getInputRoot, Func<MotionEvent, int, Point> getPointfunc)
         {
             this._view = view;
             HandleEvents = true;
@@ -19,11 +19,9 @@ namespace Avalonia.Android.Platform.Specific.Helpers
             _getInputRoot = getInputRoot;
         }
 
-        private DateTime _lastTouchMoveEventTime = DateTime.Now;
-        private Point? _lastTouchMovePoint;
-        private Func<MotionEvent, Point> _getPointFunc;
+        private TouchDevice _touchDevice = new TouchDevice();
+        private Func<MotionEvent, int, Point> _getPointFunc;
         private Func<IInputRoot> _getInputRoot;
-        private Point _point;
 
         public bool? DispatchTouchEvent(MotionEvent e, out bool callBase)
         {
@@ -33,89 +31,44 @@ namespace Avalonia.Android.Platform.Specific.Helpers
                 return null;
             }
 
-            RawPointerEventType? mouseEventType = null;
             var eventTime = DateTime.Now;
+
             //Basic touch support
-            switch (e.Action)
+            var pointerEventType = e.Action switch
             {
-                case MotionEventActions.Move:
-                    //may be bot flood the evnt system with too many event especially on not so powerfull mobile devices
-                    if ((eventTime - _lastTouchMoveEventTime).TotalMilliseconds > 10)
-                    {
-                        mouseEventType = RawPointerEventType.Move;
-                    }
-                    break;
-
-                case MotionEventActions.Down:
-                    mouseEventType = RawPointerEventType.LeftButtonDown;
+                MotionEventActions.Down => RawPointerEventType.TouchBegin,
+                MotionEventActions.Up => RawPointerEventType.TouchEnd,
+                MotionEventActions.Cancel => RawPointerEventType.TouchCancel,
+                _ => RawPointerEventType.TouchUpdate
+            };
 
-                    break;
+            if (e.Action.HasFlag(MotionEventActions.PointerDown))
+            {
+                pointerEventType = RawPointerEventType.TouchBegin;
+            }
 
-                case MotionEventActions.Up:
-                    mouseEventType = RawPointerEventType.LeftButtonUp;
-                    break;
+            if (e.Action.HasFlag(MotionEventActions.PointerUp))
+            {
+                pointerEventType = RawPointerEventType.TouchEnd;
             }
 
-            if (mouseEventType != null)
+            for (int i = 0; i < e.PointerCount; i++)
             {
                 //if point is in view otherwise it's possible avalonia not to find the proper window to dispatch the event
-                _point = _getPointFunc(e);
+                var point = _getPointFunc(e, i);
 
                 double x = _view.View.GetX();
                 double y = _view.View.GetY();
                 double r = x + _view.View.Width;
                 double b = y + _view.View.Height;
 
-                if (x <= _point.X && r >= _point.X && y <= _point.Y && b >= _point.Y)
+                if (x <= point.X && r >= point.X && y <= point.Y && b >= point.Y)
                 {
                     var inputRoot = _getInputRoot();
-                    var mouseDevice = Avalonia.Android.Platform.Input.AndroidMouseDevice.Instance;
-
-                    //in order the controls to work in a predictable way
-                    //we need to generate mouse move before first mouse down event
-                    //as this is the way buttons are working every time
-                    //otherwise there is a problem sometimes
-                    if (mouseEventType == RawPointerEventType.LeftButtonDown)
-                    {
-                        var me = new RawPointerEventArgs(mouseDevice, (uint)eventTime.Ticks, inputRoot,
-                                    RawPointerEventType.Move, _point, RawInputModifiers.None);
-                        _view.Input(me);
-                    }
 
-                    var mouseEvent = new RawPointerEventArgs(mouseDevice, (uint)eventTime.Ticks, inputRoot,
-                        mouseEventType.Value, _point, RawInputModifiers.LeftMouseButton);
+                    var mouseEvent = new RawTouchEventArgs(_touchDevice, (uint)eventTime.Ticks, inputRoot,
+                        i == e.ActionIndex ? pointerEventType : RawPointerEventType.TouchUpdate, point, RawInputModifiers.None, e.GetPointerId(i));
                     _view.Input(mouseEvent);
-
-                    if (e.Action == MotionEventActions.Move && mouseDevice.Captured == null)
-                    {
-                        if (_lastTouchMovePoint != null)
-                        {
-                            //raise mouse scroll event so the scrollers
-                            //are moving with the cursor
-                            double vectorX = _point.X - _lastTouchMovePoint.Value.X;
-                            double vectorY = _point.Y - _lastTouchMovePoint.Value.Y;
-                            //based on test correction of 0.02 is working perfect
-                            double correction = 0.02;
-                            var ps = AndroidPlatform.Instance.LayoutScalingFactor;
-                            var mouseWheelEvent = new RawMouseWheelEventArgs(
-                                        mouseDevice,
-                                        (uint)eventTime.Ticks,
-                                        inputRoot,
-                                        _point,
-                                        new Vector(vectorX * correction / ps, vectorY * correction / ps), RawInputModifiers.LeftMouseButton);
-                            _view.Input(mouseWheelEvent);
-                        }
-                        _lastTouchMovePoint = _point;
-                        _lastTouchMoveEventTime = eventTime;
-                    }
-                    else if (e.Action == MotionEventActions.Down)
-                    {
-                        _lastTouchMovePoint = _point;
-                    }
-                    else
-                    {
-                        _lastTouchMovePoint = null;
-                    }
                 }
             }
 

+ 0 - 11
src/Android/Avalonia.Android/app.config

@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<configuration>
-  <runtime>
-    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
-      <dependentAssembly>
-        <assemblyIdentity name="System.Runtime.InteropServices.WindowsRuntime" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
-        <bindingRedirect oldVersion="0.0.0.0-4.0.1.0" newVersion="4.0.1.0" />
-      </dependentAssembly>
-    </assemblyBinding>
-  </runtime>
-</configuration>

+ 2 - 2
src/Android/Avalonia.AndroidTestApplication/Avalonia.AndroidTestApplication.csproj

@@ -16,7 +16,7 @@
     <AndroidResgenFile>Resources\Resource.Designer.cs</AndroidResgenFile>
     <GenerateSerializationAssemblies>Off</GenerateSerializationAssemblies>
     <AndroidUseLatestPlatformSdk>False</AndroidUseLatestPlatformSdk>
-    <TargetFrameworkVersion>v10.0</TargetFrameworkVersion>
+    <TargetFrameworkVersion>v11.0</TargetFrameworkVersion>
     <AndroidManifest>Properties\AndroidManifest.xml</AndroidManifest>
   </PropertyGroup>
   <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
@@ -150,4 +150,4 @@
   <Import Project="..\..\..\build\System.Memory.props" />
   <Import Project="..\..\..\build\AndroidWorkarounds.props" />
   <Import Project="..\..\..\build\LegacyProject.targets" />
-</Project>
+</Project>

+ 1 - 1
src/Android/Avalonia.AndroidTestApplication/Properties/AndroidManifest.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="Avalonia.AndroidTestApplication" android:versionCode="1" android:versionName="1.0" android:installLocation="auto">
-	<uses-sdk android:targetSdkVersion="29" />
+	<uses-sdk android:targetSdkVersion="30" />
 	<application android:label="Avalonia.AndroidTestApplication" android:icon="@drawable/Icon" android:hardwareAccelerated="true"></application>
 	<uses-permission android:name="android.permission.INTERNET" />
 </manifest>

+ 3 - 1
src/Avalonia.Controls/ApiCompatBaseline.txt

@@ -1,7 +1,9 @@
 Compat issues with assembly Avalonia.Controls:
+InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Controls.INativeMenuExporterEventsImplBridge.RaiseClosed()' is present in the implementation but not in the contract.
+InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Controls.INativeMenuExporterEventsImplBridge.RaiseOpening()' is present in the implementation but not in the contract.
 MembersMustExist : Member 'public void Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetCursor(Avalonia.Platform.IPlatformHandle)' does not exist in the implementation but it does exist in the contract.
 MembersMustExist : Member 'public Avalonia.AvaloniaProperty Avalonia.AvaloniaProperty Avalonia.Controls.Notifications.NotificationCard.CloseOnClickProperty' does not exist in the implementation but it does exist in the contract.
 InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.ITopLevelImpl.SetCursor(Avalonia.Platform.ICursorImpl)' is present in the implementation but not in the contract.
 InterfacesShouldHaveSameMembers : Interface member 'public void Avalonia.Platform.ITopLevelImpl.SetCursor(Avalonia.Platform.IPlatformHandle)' is present in the contract but not in the implementation.
 MembersMustExist : Member 'public void Avalonia.Platform.ITopLevelImpl.SetCursor(Avalonia.Platform.IPlatformHandle)' does not exist in the implementation but it does exist in the contract.
-Total Issues: 5
+Total Issues: 7

+ 5 - 3
src/Avalonia.Controls/AutoCompleteBox.cs

@@ -483,7 +483,9 @@ namespace Avalonia.Controls
             AvaloniaProperty.RegisterDirect<AutoCompleteBox, object>(
                 nameof(SelectedItem),
                 o => o.SelectedItem,
-                (o, v) => o.SelectedItem = v);
+                (o, v) => o.SelectedItem = v,
+                defaultBindingMode: BindingMode.TwoWay,
+                enableDataValidation: true);
 
         /// <summary>
         /// Identifies the
@@ -1333,7 +1335,7 @@ namespace Avalonia.Controls
 
             base.OnApplyTemplate(e);
         }
-        
+
         /// <summary>
         /// Called to update the validation state for properties for which data validation is
         /// enabled.
@@ -1342,7 +1344,7 @@ namespace Avalonia.Controls
         /// <param name="value">The new binding value for the property.</param>
         protected override void UpdateDataValidation<T>(AvaloniaProperty<T> property, BindingValue<T> value)
         {
-            if (property == TextProperty)
+            if (property == TextProperty || property == SelectedItemProperty)
             {
                 DataValidationErrors.SetError(this, value.Error);
             }

+ 2 - 0
src/Avalonia.Controls/INativeMenuExporterEventsImplBridge.cs

@@ -3,5 +3,7 @@ namespace Avalonia.Controls
     public interface INativeMenuExporterEventsImplBridge
     {
         void RaiseNeedsUpdate ();
+        void RaiseOpening();
+        void RaiseClosed();
     }
 }

+ 32 - 1
src/Avalonia.Controls/NativeMenu.cs

@@ -12,13 +12,34 @@ namespace Avalonia.Controls
         private readonly AvaloniaList<NativeMenuItemBase> _items =
             new AvaloniaList<NativeMenuItemBase> { ResetBehavior = ResetBehavior.Remove };
         private NativeMenuItem _parent;
+
         [Content]
         public IList<NativeMenuItemBase> Items => _items;
 
         /// <summary>
-        /// Raised when the user clicks the menu and before its opened. Use this event to update the menu dynamically.
+        /// Raised when the menu requests an update.
+        /// </summary>
+        /// <remarks>
+        /// Use this event to add, remove or modify menu items before a menu is
+        /// shown or a hotkey is pressed.
+        /// </remarks>
+        public event EventHandler<EventArgs> NeedsUpdate;
+
+        /// <summary>
+        /// Raised before the menu is opened.
         /// </summary>
+        /// <remarks>
+        /// Do not update the menu in this event; use <see cref="NeedsUpdate"/>.
+        /// </remarks>
         public event EventHandler<EventArgs> Opening;
+        
+        /// <summary>
+        /// Raised after the menu is closed.
+        /// </summary>
+        /// <remarks>
+        /// Do not update the menu in this event; use <see cref="NeedsUpdate"/>.
+        /// </remarks>
+        public event EventHandler<EventArgs> Closed;
 
         public NativeMenu()
         {
@@ -27,10 +48,20 @@ namespace Avalonia.Controls
         }
 
         void INativeMenuExporterEventsImplBridge.RaiseNeedsUpdate()
+        {
+            NeedsUpdate?.Invoke(this, EventArgs.Empty);
+        }
+
+        void INativeMenuExporterEventsImplBridge.RaiseOpening()
         {
             Opening?.Invoke(this, EventArgs.Empty);
         }
 
+        void INativeMenuExporterEventsImplBridge.RaiseClosed()
+        {
+            Closed?.Invoke(this, EventArgs.Empty);
+        }
+
         private void Validator(NativeMenuItemBase obj)
         {
             if (obj.Parent != null)

+ 16 - 2
src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs

@@ -91,14 +91,14 @@ namespace Avalonia.Controls
         /// </summary>
         public static readonly DirectProperty<NumericUpDown, string> TextProperty =
             AvaloniaProperty.RegisterDirect<NumericUpDown, string>(nameof(Text), o => o.Text, (o, v) => o.Text = v,
-                defaultBindingMode: BindingMode.TwoWay);
+                defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true);
 
         /// <summary>
         /// Defines the <see cref="Value"/> property.
         /// </summary>
         public static readonly DirectProperty<NumericUpDown, double> ValueProperty =
             AvaloniaProperty.RegisterDirect<NumericUpDown, double>(nameof(Value), updown => updown.Value,
-                (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay);
+                (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true);
 
         /// <summary>
         /// Defines the <see cref="Watermark"/> property.
@@ -370,6 +370,20 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Called to update the validation state for properties for which data validation is
+        /// enabled.
+        /// </summary>
+        /// <param name="property">The property.</param>
+        /// <param name="value">The new binding value for the property.</param>
+        protected override void UpdateDataValidation<T>(AvaloniaProperty<T> property, BindingValue<T> value)
+        {
+            if (property == TextProperty || property == ValueProperty)
+            {
+                DataValidationErrors.SetError(this, value.Error);
+            }
+        }
+
         /// <summary>
         /// Called when the <see cref="CultureInfo"/> property value changed.
         /// </summary>

+ 1 - 1
src/Avalonia.DesignerSupport/Remote/RemoteDesignerEntryPoint.cs

@@ -169,7 +169,7 @@ namespace Avalonia.DesignerSupport.Remote
             if (entryPoint == null)
                 throw Die($"Assembly {args.AppPath} doesn't have an entry point");
             var builderMethod = entryPoint.DeclaringType.GetMethod(BuilderMethodName,
-                BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
+                BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, null, Array.Empty<Type>(), null);
             if (builderMethod == null)
                 throw Die($"{entryPoint.DeclaringType.FullName} doesn't have a method named {BuilderMethodName}");
             Design.IsDesignMode = true;

+ 22 - 0
src/Avalonia.Native/IAvnMenu.cs

@@ -20,11 +20,23 @@ namespace Avalonia.Native.Interop
         {
             _parent?.RaiseNeedsUpdate();
         }
+
+        public void Opening()
+        {
+            _parent?.RaiseOpening();
+        }
+
+        public void Closed()
+        {
+            _parent?.RaiseClosed();
+        }
     }
 
     partial interface IAvnMenu
     {
         void RaiseNeedsUpdate();
+        void RaiseOpening();
+        void RaiseClosed();
         void Deinitialise();
     }
 }
@@ -45,6 +57,16 @@ namespace Avalonia.Native.Interop.Impl
             _exporter.UpdateIfNeeded();
         }
 
+        public void RaiseOpening()
+        {
+            (ManagedMenu as INativeMenuExporterEventsImplBridge).RaiseOpening();
+        }
+
+        public void RaiseClosed()
+        {
+            (ManagedMenu as INativeMenuExporterEventsImplBridge).RaiseClosed();
+        }
+
         internal NativeMenu ManagedMenu { get; private set; }
 
         public static __MicroComIAvnMenuProxy Create(IAvaloniaNativeFactory factory)

+ 2 - 3
src/Avalonia.Native/avn.idl

@@ -685,10 +685,9 @@ interface IAvnMenuItem : IUnknown
 [uuid(0af7df53-7632-42f4-a650-0992c361b477)]
 interface IAvnMenuEvents : IUnknown
 {
-    /**
-     * NeedsUpdate
-     */
      void NeedsUpdate();
+     void Opening();
+     void Closed();
 }
 
 [uuid(5142bb41-66ab-49e7-bb37-cd079c000f27)]

+ 32 - 0
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<BindingNotification>(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<BindingNotification>(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);
+            });
+        }
 
         /// <summary>
         /// Retrieves a defined predicate filter through a new AutoCompleteBox 

+ 95 - 0
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<BindingNotification>(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<BindingNotification>(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<NumericUpDown, TextBox> 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<ButtonSpinner>()
+                          .Select(b => b.Content)
+                          .OfType<TextBox>()
+                          .First();
+        }
+        private IControlTemplate CreateTemplate()
+        {
+            return new FuncControlTemplate<NumericUpDown>((control, scope) =>
+            {
+                var textBox =
+                    new TextBox
+                    {
+                        Name = "PART_TextBox"
+                    }.RegisterInNameScope(scope);
+                return new ButtonSpinner
+                    {
+                        Name = "PART_Spinner",
+                        Content = textBox,
+                    }.RegisterInNameScope(scope);
+            });
+        }
+    }
+}