Browse Source

Merge pull request #4173 from AvaloniaUI/feature/effectiveviewportchanged

Added EffectiveViewportChanged event.
Dariusz Komosiński 5 years ago
parent
commit
69ee16adca

+ 16 - 44
src/Avalonia.Controls/Repeater/ViewportManager.cs

@@ -49,8 +49,8 @@ namespace Avalonia.Controls
         // For non-virtualizing layouts, we do not need to keep
         // updating viewports and invalidating measure often. So when
         // a non virtualizing layout is used, we stop doing all that work.
-        bool _managingViewportDisabled;
-        private IDisposable _effectiveViewportChangedRevoker;
+        private bool _managingViewportDisabled;
+        private bool _effectiveViewportChangedSubscribed;
         private bool _layoutUpdatedSubscribed;
 
         public ViewportManager(ItemsRepeater owner)
@@ -228,11 +228,15 @@ namespace Avalonia.Controls
             _pendingViewportShift = default;
             _unshiftableShift = default;
 
-            _effectiveViewportChangedRevoker?.Dispose();
-
-            if (!_managingViewportDisabled)
+            if (_managingViewportDisabled && _effectiveViewportChangedSubscribed)
+            {
+                _owner.EffectiveViewportChanged -= OnEffectiveViewportChanged;
+                _effectiveViewportChangedSubscribed = false;
+            }
+            else if (!_managingViewportDisabled && !_effectiveViewportChangedSubscribed)
             {
-                _effectiveViewportChangedRevoker = SubscribeToEffectiveViewportChanged(_owner);
+                _owner.EffectiveViewportChanged += OnEffectiveViewportChanged;
+                _effectiveViewportChangedSubscribed = true;
             }
         }
 
@@ -420,15 +424,15 @@ namespace Avalonia.Controls
                 _scroller = null;
             }
 
-            _effectiveViewportChangedRevoker?.Dispose();
-            _effectiveViewportChangedRevoker = null;
+            _owner.EffectiveViewportChanged -= OnEffectiveViewportChanged;
+            _effectiveViewportChangedSubscribed = false;
             _ensuredScroller = false;
         }
 
-        private void OnEffectiveViewportChanged(Rect effectiveViewport)
+        private void OnEffectiveViewportChanged(object sender, EffectiveViewportChangedEventArgs e)
         {
             Logger.TryGet(LogEventLevel.Verbose, "Repeater")?.Log(this, "{LayoutId}: EffectiveViewportChanged event callback", _owner.Layout.LayoutId);
-            UpdateViewport(effectiveViewport);
+            UpdateViewport(e.EffectiveViewport);
 
             _pendingViewportShift = default;
             _unshiftableShift = default;
@@ -473,8 +477,8 @@ namespace Avalonia.Controls
                 }
                 else if (!_managingViewportDisabled)
                 {
-                    _effectiveViewportChangedRevoker?.Dispose();
-                    _effectiveViewportChangedRevoker = SubscribeToEffectiveViewportChanged(_owner);
+                    _owner.EffectiveViewportChanged += OnEffectiveViewportChanged;
+                    _effectiveViewportChangedSubscribed = true;
                 }
 
                 _ensuredScroller = true;
@@ -534,38 +538,6 @@ namespace Avalonia.Controls
             }
         }
 
-        private IDisposable SubscribeToEffectiveViewportChanged(IControl control)
-        {
-            // HACK: This is a bit of a hack. We need the effective viewport of the ItemsRepeater -
-            // we can get this from TransformedBounds, but this property is updated after layout has
-            // run, which is too late. Instead, for now lets just hook into an internal event on
-            // ScrollContentPresenter to find out what the offset and viewport will be after arrange
-            // and use those values. Note that this doesn't handle nested ScrollViewers at all, but
-            // it's enough to get scrolling to non-uniformly sized items working for now.
-            //
-            // UWP uses the EffectiveViewportChanged event (which I think was implemented specially
-            // for this case): we need to implement that in Avalonia, but the semantics of it aren't
-            // clear to me. Hopefully the source for this event will be released with WinUI 3.
-            if (control.VisualParent is ScrollContentPresenter scp)
-            {
-                scp.PreArrange += ScrollContentPresenterPreArrange;
-                return Disposable.Create(() => scp.PreArrange -= ScrollContentPresenterPreArrange);
-            }
-
-            return Disposable.Empty;
-        }
-
-        private void ScrollContentPresenterPreArrange(object sender, VectorEventArgs e)
-        {
-            var scp = (ScrollContentPresenter)sender;
-            var effectiveViewport = new Rect((Point)scp.Offset, new Size(e.Vector.X, e.Vector.Y));
-
-            if (effectiveViewport != _visibleWindow)
-            {
-                OnEffectiveViewportChanged(effectiveViewport);
-            }
-        }
-
         private class ScrollerInfo
         {
             public ScrollerInfo(ScrollViewer scroller)

+ 24 - 0
src/Avalonia.Layout/EffectiveViewportChangedEventArgs.cs

@@ -0,0 +1,24 @@
+using System;
+
+namespace Avalonia.Layout
+{
+    /// <summary>
+    /// Provides data for the <see cref="Layoutable.EffectiveViewportChanged"/> event.
+    /// </summary>
+    public class EffectiveViewportChangedEventArgs : EventArgs
+    {
+        public EffectiveViewportChangedEventArgs(Rect effectiveViewport)
+        {
+            EffectiveViewport = effectiveViewport;
+        }
+
+        /// <summary>
+        /// Gets the <see cref="Rect"/> representing the effective viewport.
+        /// </summary>
+        /// <remarks>
+        /// The viewport is expressed in coordinates relative to the control that the event is
+        /// raised on.
+        /// </remarks>
+        public Rect EffectiveViewport { get; }
+    }
+}

+ 12 - 0
src/Avalonia.Layout/ILayoutManager.cs

@@ -54,5 +54,17 @@ namespace Avalonia.Layout
         /// </remarks>
         [Obsolete("Call ExecuteInitialLayoutPass without parameter")]
         void ExecuteInitialLayoutPass(ILayoutRoot root);
+
+        /// <summary>
+        /// Registers a control as wanting to receive effective viewport notifications.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        void RegisterEffectiveViewportListener(ILayoutable control);
+
+        /// <summary>
+        /// Registers a control as no longer wanting to receive effective viewport notifications.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        void UnregisterEffectiveViewportListener(ILayoutable control);
     }
 }

+ 7 - 0
src/Avalonia.Layout/ILayoutable.cs

@@ -111,5 +111,12 @@ namespace Avalonia.Layout
         /// </summary>
         /// <param name="control">The child control.</param>
         void ChildDesiredSizeChanged(ILayoutable control);
+
+        /// <summary>
+        /// Used by the <see cref="LayoutManager"/> to notify the control that its effective
+        /// viewport is changed.
+        /// </summary>
+        /// <param name="e">The viewport information.</param>
+        void EffectiveViewportChanged(EffectiveViewportChangedEventArgs e);
     }
 }

+ 145 - 15
src/Avalonia.Layout/LayoutManager.cs

@@ -1,7 +1,10 @@
 using System;
+using System.Buffers;
+using System.Collections.Generic;
 using System.Diagnostics;
 using Avalonia.Logging;
 using Avalonia.Threading;
+using Avalonia.VisualTree;
 
 #nullable enable
 
@@ -12,10 +15,12 @@ namespace Avalonia.Layout
     /// </summary>
     public class LayoutManager : ILayoutManager, IDisposable
     {
+        private const int MaxPasses = 3;
         private readonly ILayoutRoot _owner;
         private readonly LayoutQueue<ILayoutable> _toMeasure = new LayoutQueue<ILayoutable>(v => !v.IsMeasureValid);
         private readonly LayoutQueue<ILayoutable> _toArrange = new LayoutQueue<ILayoutable>(v => !v.IsArrangeValid);
         private readonly Action _executeLayoutPass;
+        private List<EffectiveViewportChangedListener>? _effectiveViewportChangedListeners;
         private bool _disposed;
         private bool _queued;
         private bool _running;
@@ -92,8 +97,6 @@ namespace Avalonia.Layout
         /// <inheritdoc/>
         public virtual void ExecuteLayoutPass()
         {
-            const int MaxPasses = 3;
-
             Dispatcher.UIThread.VerifyAccess();
 
             if (_disposed)
@@ -125,23 +128,15 @@ namespace Avalonia.Layout
                 _toMeasure.BeginLoop(MaxPasses);
                 _toArrange.BeginLoop(MaxPasses);
 
-                try
+                for (var pass = 0; pass < MaxPasses; ++pass)
                 {
-                    for (var pass = 0; pass < MaxPasses; ++pass)
-                    {
-                        ExecuteMeasurePass();
-                        ExecuteArrangePass();
+                    InnerLayoutPass();
 
-                        if (_toMeasure.Count == 0)
-                        {
-                            break;
-                        }
+                    if (!RaiseEffectiveViewportChanged())
+                    {
+                        break;
                     }
                 }
-                finally
-                {
-                    _running = false;
-                }
 
                 _toMeasure.EndLoop();
                 _toArrange.EndLoop();
@@ -202,6 +197,49 @@ namespace Avalonia.Layout
             _toArrange.Dispose();
         }
 
+        void ILayoutManager.RegisterEffectiveViewportListener(ILayoutable control)
+        {
+            _effectiveViewportChangedListeners ??= new List<EffectiveViewportChangedListener>();
+            _effectiveViewportChangedListeners.Add(new EffectiveViewportChangedListener(
+                control,
+                CalculateEffectiveViewport(control)));
+        }
+
+        void ILayoutManager.UnregisterEffectiveViewportListener(ILayoutable control)
+        {
+            if (_effectiveViewportChangedListeners is object)
+            {
+                for (var i = _effectiveViewportChangedListeners.Count - 1; i >= 0; --i)
+                {
+                    if (_effectiveViewportChangedListeners[i].Listener == control)
+                    {
+                        _effectiveViewportChangedListeners.RemoveAt(i);
+                    }
+                }
+            }
+        }
+
+        private void InnerLayoutPass()
+        {
+            try
+            {
+                for (var pass = 0; pass < MaxPasses; ++pass)
+                {
+                    ExecuteMeasurePass();
+                    ExecuteArrangePass();
+
+                    if (_toMeasure.Count == 0)
+                    {
+                        break;
+                    }
+                }
+            }
+            finally
+            {
+                _running = false;
+            }
+        }
+
         private void ExecuteMeasurePass()
         {
             while (_toMeasure.Count > 0)
@@ -285,5 +323,97 @@ namespace Avalonia.Layout
                 _queued = true;
             }
         }
+
+        private bool RaiseEffectiveViewportChanged()
+        {
+            var startCount = _toMeasure.Count + _toArrange.Count;
+
+            if (_effectiveViewportChangedListeners is object)
+            {
+                var count = _effectiveViewportChangedListeners.Count;
+                var pool = ArrayPool<EffectiveViewportChangedListener>.Shared;
+                var listeners = pool.Rent(count);
+
+                _effectiveViewportChangedListeners.CopyTo(listeners);
+
+                try
+                {
+                    for (var i = 0; i < count; ++i)
+                    {
+                        var l = _effectiveViewportChangedListeners[i];
+
+                        if (!l.Listener.IsAttachedToVisualTree)
+                        {
+                            continue;
+                        }
+
+                        var viewport = CalculateEffectiveViewport(l.Listener);
+
+                        if (viewport != l.Viewport)
+                        {
+                            l.Listener.EffectiveViewportChanged(new EffectiveViewportChangedEventArgs(viewport));
+                            _effectiveViewportChangedListeners[i] = new EffectiveViewportChangedListener(l.Listener, viewport);
+                        }
+                    }
+                }
+                finally
+                {
+                    pool.Return(listeners, clearArray: true);
+                }
+            }
+
+            return startCount != _toMeasure.Count + _toMeasure.Count;
+        }
+
+        private Rect CalculateEffectiveViewport(IVisual control)
+        {
+            var viewport = new Rect(0, 0, double.PositiveInfinity, double.PositiveInfinity);
+            CalculateEffectiveViewport(control, control, ref viewport);
+            return viewport;
+        }
+
+        private void CalculateEffectiveViewport(IVisual target, IVisual control, ref Rect viewport)
+        {
+            // Recurse until the top level control.
+            if (control.VisualParent is object)
+            {
+                CalculateEffectiveViewport(target, control.VisualParent, ref viewport);
+            }
+            else
+            {
+                viewport = new Rect(control.Bounds.Size);
+            }
+
+            // Apply the control clip bounds if it's not the target control. We don't apply it to
+            // the target control because it may itself be clipped to bounds and if so the viewport
+            // we calculate would be of no use.
+            if (control != target && control.ClipToBounds)
+            {
+                viewport = control.Bounds.Intersect(viewport);
+            }
+
+            // Translate the viewport into this control's coordinate space.
+            viewport = viewport.Translate(-control.Bounds.Position);
+
+            if (control != target && control.RenderTransform is object)
+            {
+                var origin = control.RenderTransformOrigin.ToPixels(control.Bounds.Size);
+                var offset = Matrix.CreateTranslation(origin);
+                var renderTransform = (-offset) * control.RenderTransform.Value.Invert() * (offset);
+                viewport = viewport.TransformToAABB(renderTransform);
+            }
+        }
+
+        private readonly struct EffectiveViewportChangedListener
+        {
+            public EffectiveViewportChangedListener(ILayoutable listener, Rect viewport)
+            {
+                Listener = listener;
+                Viewport = viewport;
+            }
+
+            public ILayoutable Listener { get; }
+            public Rect Viewport { get; }
+        }
     }
 }

+ 59 - 11
src/Avalonia.Layout/Layoutable.cs

@@ -132,6 +132,7 @@ namespace Avalonia.Layout
         private bool _measuring;
         private Size? _previousMeasure;
         private Rect? _previousArrange;
+        private EventHandler<EffectiveViewportChangedEventArgs>? _effectiveViewportChanged;
         private EventHandler? _layoutUpdated;
 
         /// <summary>
@@ -152,6 +153,32 @@ namespace Avalonia.Layout
                 VerticalAlignmentProperty);
         }
 
+        /// <summary>
+        /// Occurs when the element's effective viewport changes.
+        /// </summary>
+        public event EventHandler<EffectiveViewportChangedEventArgs>? EffectiveViewportChanged
+        {
+            add
+            {
+                if (_effectiveViewportChanged is null && VisualRoot is ILayoutRoot r)
+                {
+                    r.LayoutManager.RegisterEffectiveViewportListener(this);
+                }
+
+                _effectiveViewportChanged += value;
+            }
+
+            remove
+            {
+                _effectiveViewportChanged -= value;
+
+                if (_effectiveViewportChanged is null && VisualRoot is ILayoutRoot r)
+                {
+                    r.LayoutManager.UnregisterEffectiveViewportListener(this);
+                }
+            }
+        }
+
         /// <summary>
         /// Occurs when a layout pass completes for the control.
         /// </summary>
@@ -384,13 +411,6 @@ namespace Avalonia.Layout
             }
         }
 
-        /// <summary>
-        /// Called by InvalidateMeasure
-        /// </summary>
-        protected virtual void OnMeasureInvalidated()
-        {
-        }
-
         /// <summary>
         /// Invalidates the measurement of the control and queues a new layout pass.
         /// </summary>
@@ -436,6 +456,11 @@ namespace Avalonia.Layout
             }
         }
 
+        void ILayoutable.EffectiveViewportChanged(EffectiveViewportChangedEventArgs e)
+        {
+            _effectiveViewportChanged?.Invoke(this, e);
+        }
+
         /// <summary>
         /// Marks a property as affecting the control's measurement.
         /// </summary>
@@ -717,9 +742,17 @@ namespace Avalonia.Layout
         {
             base.OnAttachedToVisualTreeCore(e);
 
-            if (_layoutUpdated is object && e.Root is ILayoutRoot r)
+            if (e.Root is ILayoutRoot r)
             {
-                r.LayoutManager.LayoutUpdated += LayoutManagedLayoutUpdated;
+                if (_layoutUpdated is object)
+                {
+                    r.LayoutManager.LayoutUpdated += LayoutManagedLayoutUpdated;
+                }
+
+                if (_effectiveViewportChanged is object)
+                {
+                    r.LayoutManager.RegisterEffectiveViewportListener(this);
+                }
             }
         }
 
@@ -727,12 +760,27 @@ namespace Avalonia.Layout
         {
             base.OnDetachedFromVisualTreeCore(e);
 
-            if (_layoutUpdated is object && e.Root is ILayoutRoot r)
+            if (e.Root is ILayoutRoot r)
             {
-                r.LayoutManager.LayoutUpdated -= LayoutManagedLayoutUpdated;
+                if (_layoutUpdated is object)
+                {
+                    r.LayoutManager.LayoutUpdated -= LayoutManagedLayoutUpdated;
+                }
+
+                if (_effectiveViewportChanged is object)
+                {
+                    r.LayoutManager.UnregisterEffectiveViewportListener(this);
+                }
             }
         }
 
+        /// <summary>
+        /// Called by InvalidateMeasure
+        /// </summary>
+        protected virtual void OnMeasureInvalidated()
+        {
+        }
+
         /// <inheritdoc/>
         protected sealed override void OnVisualParentChanged(IVisual oldParent, IVisual newParent)
         {

+ 424 - 0
tests/Avalonia.Layout.UnitTests/LayoutableTests_EffectiveViewportChanged.cs

@@ -0,0 +1,424 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Media;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Layout.UnitTests
+{
+    public class LayoutableTests_EffectiveViewportChanged
+    {
+        [Fact]
+        public async Task EffectiveViewportChanged_Not_Raised_When_Control_Added_To_Tree()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas();
+                var raised = 0;
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    ++raised;
+                };
+
+                root.Child = target;
+
+                Assert.Equal(0, raised);
+            });
+        }
+
+        [Fact]
+        public async Task EffectiveViewportChanged_Raised_Before_LayoutUpdated()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas();
+                var raised = 0;
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    ++raised;
+                };
+
+                root.Child = target;
+
+                await ExecuteInitialLayoutPass(root);
+
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Parent_Affects_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 100, Height = 100 };
+                var parent = new Border { Width = 200, Height = 200, Child = target };
+                var raised = 0;
+
+                root.Child = parent;
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    Assert.Equal(new Rect(-550, -400, 1200, 900), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                await ExecuteInitialLayoutPass(root);
+            });
+        }
+
+        [Fact]
+        public async Task Invalidating_In_Handler_Causes_Layout_To_Be_Rerun_Before_LayoutUpdated_Raised()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new TestCanvas();
+                var raised = 0;
+                var layoutUpdatedRaised = 0;
+
+                root.LayoutUpdated += (s, e) =>
+                {
+                    Assert.Equal(2, target.MeasureCount);
+                    Assert.Equal(2, target.ArrangeCount);
+                    ++layoutUpdatedRaised;
+                };
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    target.InvalidateMeasure();
+                    ++raised;
+                };
+
+                root.Child = target;
+
+                await ExecuteInitialLayoutPass(root);
+
+                Assert.Equal(1, raised);
+                Assert.Equal(1, layoutUpdatedRaised);
+            });
+        }
+
+        [Fact]
+        public async Task Viewport_Extends_Beyond_Centered_Control()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 52, Height = 52, };
+                var raised = 0;
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    Assert.Equal(new Rect(-574, -424, 1200, 900), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                root.Child = target;
+
+                await ExecuteInitialLayoutPass(root);
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Viewport_Extends_Beyond_Nested_Centered_Control()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 52, Height = 52 };
+                var parent = new Border { Width = 100, Height = 100, Child = target };
+                var raised = 0;
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    Assert.Equal(new Rect(-574, -424, 1200, 900), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                root.Child = parent;
+
+                await ExecuteInitialLayoutPass(root);
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task ScrollViewer_Determines_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 200, Height = 200 };
+                var scroller = new ScrollViewer { Width = 100, Height = 100, Content = target, Template = ScrollViewerTemplate() };
+                var raised = 0;
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    Assert.Equal(new Rect(0, 0, 100, 100), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                root.Child = scroller;
+
+                await ExecuteInitialLayoutPass(root);
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Scrolled_ScrollViewer_Determines_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 200, Height = 200 };
+                var scroller = new ScrollViewer { Width = 100, Height = 100, Content = target, Template = ScrollViewerTemplate() };
+                var raised = 0;
+
+                root.Child = scroller;
+
+                await ExecuteInitialLayoutPass(root);
+                scroller.Offset = new Vector(0, 10);
+
+                await ExecuteScrollerLayoutPass(root, scroller, target, (s, e) =>
+                {
+                    Assert.Equal(new Rect(0, 10, 100, 100), e.EffectiveViewport);
+                    ++raised;
+                });
+
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Moving_Parent_Updates_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 100, Height = 100 };
+                var parent = new Border { Width = 200, Height = 200, Child = target };
+                var raised = 0;
+
+                root.Child = parent;
+
+                await ExecuteInitialLayoutPass(root);
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    Assert.Equal(new Rect(-554, -400, 1200, 900), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                parent.Margin = new Thickness(8, 0, 0, 0);
+                await ExecuteLayoutPass(root);
+
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Translate_Transform_Doesnt_Affect_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 100, Height = 100 };
+                var parent = new Border { Width = 200, Height = 200, Child = target };
+                var raised = 0;
+
+                root.Child = parent;
+
+                await ExecuteInitialLayoutPass(root);
+                target.EffectiveViewportChanged += (s, e) => ++raised;
+                target.RenderTransform = new TranslateTransform { X = 8 };
+                target.InvalidateMeasure();
+                await ExecuteLayoutPass(root);
+
+                Assert.Equal(0, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Translate_Transform_On_Parent_Affects_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 100, Height = 100 };
+                var parent = new Border { Width = 200, Height = 200, Child = target };
+                var raised = 0;
+
+                root.Child = parent;
+
+                await ExecuteInitialLayoutPass(root);
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    Assert.Equal(new Rect(-558, -400, 1200, 900), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                // Change the parent render transform to move it. A layout is then needed before
+                // EffectiveViewportChanged is raised.
+                parent.RenderTransform = new TranslateTransform { X = 8 };
+                parent.InvalidateMeasure();
+                await ExecuteLayoutPass(root);
+
+                Assert.Equal(1, raised);
+            });
+        }
+
+        [Fact]
+        public async Task Rotate_Transform_On_Parent_Affects_EffectiveViewport()
+        {
+            await RunOnUIThread.Execute(async () =>
+            {
+                var root = CreateRoot();
+                var target = new Canvas { Width = 100, Height = 100 };
+                var parent = new Border { Width = 200, Height = 200, Child = target };
+                var raised = 0;
+
+                root.Child = parent;
+
+                await ExecuteInitialLayoutPass(root);
+
+                target.EffectiveViewportChanged += (s, e) =>
+                {
+                    AssertArePixelEqual(new Rect(-651, -792, 1484, 1484), e.EffectiveViewport);
+                    ++raised;
+                };
+
+                parent.RenderTransformOrigin = new RelativePoint(0, 0, RelativeUnit.Absolute);
+                parent.RenderTransform = new RotateTransform { Angle = 45 };
+                parent.InvalidateMeasure();
+                await ExecuteLayoutPass(root);
+
+                Assert.Equal(1, raised);
+            });
+        }
+
+        private TestRoot CreateRoot() => new TestRoot { Width = 1200, Height = 900 };
+
+        private Task ExecuteInitialLayoutPass(TestRoot root)
+        {
+            root.LayoutManager.ExecuteInitialLayoutPass();
+            return Task.CompletedTask;
+        }
+
+        private Task ExecuteLayoutPass(TestRoot root)
+        {
+            root.LayoutManager.ExecuteLayoutPass();
+            return Task.CompletedTask;
+        }
+
+        private Task ExecuteScrollerLayoutPass(
+            TestRoot root,
+            ScrollViewer scroller,
+            Control target,
+            Action<object, EffectiveViewportChangedEventArgs> handler)
+        {
+            void ViewportChanged(object sender, EffectiveViewportChangedEventArgs e)
+            {
+                handler(sender, e);
+            }
+
+            target.EffectiveViewportChanged += ViewportChanged;
+            root.LayoutManager.ExecuteLayoutPass();
+            return Task.CompletedTask;
+        }
+        private IControlTemplate ScrollViewerTemplate()
+        {
+            return new FuncControlTemplate<ScrollViewer>((control, scope) => new Grid
+            {
+                ColumnDefinitions = new ColumnDefinitions
+                {
+                    new ColumnDefinition(1, GridUnitType.Star),
+                    new ColumnDefinition(GridLength.Auto),
+                },
+                RowDefinitions = new RowDefinitions
+                {
+                    new RowDefinition(1, GridUnitType.Star),
+                    new RowDefinition(GridLength.Auto),
+                },
+                Children =
+                {
+                    new ScrollContentPresenter
+                    {
+                        Name = "PART_ContentPresenter",
+                        [~ContentPresenter.ContentProperty] = control[~ContentControl.ContentProperty],
+                        [~~ScrollContentPresenter.ExtentProperty] = control[~~ScrollViewer.ExtentProperty],
+                        [~~ScrollContentPresenter.OffsetProperty] = control[~~ScrollViewer.OffsetProperty],
+                        [~~ScrollContentPresenter.ViewportProperty] = control[~~ScrollViewer.ViewportProperty],
+                        [~ScrollContentPresenter.CanHorizontallyScrollProperty] = control[~ScrollViewer.CanHorizontallyScrollProperty],
+                        [~ScrollContentPresenter.CanVerticallyScrollProperty] = control[~ScrollViewer.CanVerticallyScrollProperty],
+                    }.RegisterInNameScope(scope),
+                    new ScrollBar
+                    {
+                        Name = "horizontalScrollBar",
+                        Orientation = Orientation.Horizontal,
+                        [~RangeBase.MaximumProperty] = control[~ScrollViewer.HorizontalScrollBarMaximumProperty],
+                        [~~RangeBase.ValueProperty] = control[~~ScrollViewer.HorizontalScrollBarValueProperty],
+                        [~ScrollBar.ViewportSizeProperty] = control[~ScrollViewer.HorizontalScrollBarViewportSizeProperty],
+                        [~ScrollBar.VisibilityProperty] = control[~ScrollViewer.HorizontalScrollBarVisibilityProperty],
+                        [Grid.RowProperty] = 1,
+                    }.RegisterInNameScope(scope),
+                    new ScrollBar
+                    {
+                        Name = "verticalScrollBar",
+                        Orientation = Orientation.Vertical,
+                        [~RangeBase.MaximumProperty] = control[~ScrollViewer.VerticalScrollBarMaximumProperty],
+                        [~~RangeBase.ValueProperty] = control[~~ScrollViewer.VerticalScrollBarValueProperty],
+                        [~ScrollBar.ViewportSizeProperty] = control[~ScrollViewer.VerticalScrollBarViewportSizeProperty],
+                        [~ScrollBar.VisibilityProperty] = control[~ScrollViewer.VerticalScrollBarVisibilityProperty],
+                        [Grid.ColumnProperty] = 1,
+                    }.RegisterInNameScope(scope),
+                },
+            });
+        }
+
+        private void AssertArePixelEqual(Rect expected, Rect actual)
+        {
+            var expectedRounded = new Rect((int)expected.X, (int)expected.Y, (int)expected.Width, (int)expected.Height);
+            var actualRounded = new Rect((int)actual.X, (int)actual.Y, (int)actual.Width, (int)actual.Height);
+            Assert.Equal(expectedRounded, actualRounded);
+        }
+
+        private class TestCanvas : Canvas
+        {
+            public int MeasureCount { get; private set; }
+            public int ArrangeCount { get; private set; }
+
+            protected override Size MeasureOverride(Size availableSize)
+            {
+                ++MeasureCount;
+                return base.MeasureOverride(availableSize);
+            }
+
+            protected override Size ArrangeOverride(Size finalSize)
+            {
+                ++ArrangeCount;
+                return base.ArrangeOverride(finalSize);
+            }
+        }
+
+        private static class RunOnUIThread
+        {
+            public static async Task Execute(Func<Task> func)
+            {
+                await func();
+            }
+        }
+    }
+}