diff --git a/packages/Avalonia/AvaloniaBuildTasks.props b/packages/Avalonia/AvaloniaBuildTasks.props index deea3aa391..50dc7b615f 100644 --- a/packages/Avalonia/AvaloniaBuildTasks.props +++ b/packages/Avalonia/AvaloniaBuildTasks.props @@ -5,7 +5,7 @@ - - + + diff --git a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs index 3d826d8a9c..c52536d157 100644 --- a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs +++ b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs @@ -1,4 +1,6 @@ -using Avalonia; +using System; +using Avalonia; +using Avalonia.Animation.Easings; using Avalonia.Controls; using Avalonia.Controls.Platform; using MiniMvvm; @@ -12,7 +14,26 @@ namespace SafeAreaDemo.ViewModels private IInsetsManager? _insetsManager; private bool _hideSystemBars; private bool _autoSafeAreaPadding; + private IInputPane? _inputPane; + public InputPaneState InputPaneState + { + get + { + return _inputPane?.State ?? InputPaneState.Closed; + } + } + + public IEasing? InputPaneEasing { get; private set; } + public TimeSpan? InputPaneDuration { get; private set; } + + public Thickness InputPaneMarkerMargin => InputPaneState == InputPaneState.Open + ? new Thickness(0, 0, 0, Math.Max(0, CanvasSize.Height - InputPaneRect.Top)) + : default; + public Rect InputPaneRect => _inputPane?.OccludedRect ?? default; + + public Rect CanvasSize { get; set; } + public Thickness SafeAreaPadding { get @@ -90,12 +111,16 @@ namespace SafeAreaDemo.ViewModels } } - internal void Initialize(Control mainView, IInsetsManager? InsetsManager) + internal void Initialize(Control mainView, IInsetsManager? InsetsManager, IInputPane? inputPane) { if (_insetsManager != null) { _insetsManager.SafeAreaChanged -= InsetsManager_SafeAreaChanged; } + if (_inputPane != null) + { + _inputPane.StateChanged -= InputPaneOnStateChanged; + } _autoSafeAreaPadding = mainView.GetValue(TopLevel.AutoSafeAreaPaddingProperty); _insetsManager = InsetsManager; @@ -107,6 +132,20 @@ namespace SafeAreaDemo.ViewModels _displayEdgeToEdge = _insetsManager.DisplayEdgeToEdge; _hideSystemBars = !(_insetsManager.IsSystemBarVisible ?? false); } + + _inputPane = inputPane; + if (_inputPane != null) + { + _inputPane.StateChanged += InputPaneOnStateChanged; + } + RaiseKeyboardChanged(); + } + + private void InputPaneOnStateChanged(object? sender, InputPaneStateEventArgs e) + { + InputPaneDuration = e.AnimationDuration; + InputPaneEasing = e.Easing ?? new LinearEasing(); + RaiseKeyboardChanged(); } private void InsetsManager_SafeAreaChanged(object? sender, SafeAreaChangedArgs e) @@ -118,6 +157,16 @@ namespace SafeAreaDemo.ViewModels { this.RaisePropertyChanged(nameof(SafeAreaPadding)); this.RaisePropertyChanged(nameof(ViewPadding)); + this.RaisePropertyChanged(nameof(InputPaneMarkerMargin)); + } + + private void RaiseKeyboardChanged() + { + this.RaisePropertyChanged(nameof(InputPaneState)); + this.RaisePropertyChanged(nameof(InputPaneRect)); + this.RaisePropertyChanged(nameof(InputPaneEasing)); + this.RaisePropertyChanged(nameof(InputPaneDuration)); + this.RaisePropertyChanged(nameof(InputPaneMarkerMargin)); } } } diff --git a/samples/SafeAreaDemo/Views/MainView.xaml b/samples/SafeAreaDemo/Views/MainView.xaml index 966b0a02ea..85163e7dad 100644 --- a/samples/SafeAreaDemo/Views/MainView.xaml +++ b/samples/SafeAreaDemo/Views/MainView.xaml @@ -11,10 +11,11 @@ Background="#ccc" TopLevel.AutoSafeAreaPadding="{Binding AutoSafeAreaPadding, Mode=TwoWay}"> - + VerticalAlignment="Stretch" + Bounds="{Binding CanvasSize, Mode=OneWayToSource}"> + + diff --git a/samples/SafeAreaDemo/Views/MainView.xaml.cs b/samples/SafeAreaDemo/Views/MainView.xaml.cs index bacb721d27..e4cd53c1a4 100644 --- a/samples/SafeAreaDemo/Views/MainView.xaml.cs +++ b/samples/SafeAreaDemo/Views/MainView.xaml.cs @@ -18,8 +18,9 @@ namespace SafeAreaDemo.Views base.OnLoaded(e); var insetsManager = TopLevel.GetTopLevel(this)?.InsetsManager; + var inputPane = TopLevel.GetTopLevel(this)?.InputPane; var viewModel = new MainViewModel(); - viewModel.Initialize(this, insetsManager); + viewModel.Initialize(this, insetsManager, inputPane); DataContext = viewModel; } } diff --git a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs index 6d8ae873a2..c38124e6da 100644 --- a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs +++ b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs @@ -2,53 +2,68 @@ using System.Collections.Generic; using Android.OS; using Android.Views; +using Android.Views.Animations; using AndroidX.Core.View; using Avalonia.Android.Platform.SkiaPlatform; +using Avalonia.Animation.Easings; using Avalonia.Controls.Platform; using Avalonia.Media; +using AndroidWindow = Android.Views.Window; namespace Avalonia.Android.Platform { - internal class AndroidInsetsManager : Java.Lang.Object, IInsetsManager, IOnApplyWindowInsetsListener, ViewTreeObserver.IOnGlobalLayoutListener + internal sealed class AndroidInsetsManager : WindowInsetsAnimationCompat.Callback, IInsetsManager, IOnApplyWindowInsetsListener, ViewTreeObserver.IOnGlobalLayoutListener, IInputPane { private readonly AvaloniaMainActivity _activity; private readonly TopLevelImpl _topLevel; - private readonly InsetsAnimationCallback _callback; private bool _displayEdgeToEdge; - private bool _usesLegacyLayouts; private bool? _systemUiVisibility; private SystemBarTheme? _statusBarTheme; private bool? _isDefaultSystemBarLightTheme; private Color? _systemBarColor; + private InputPaneState _state; + private Rect _previousRect; + private readonly bool _usesLegacyLayouts; + private AndroidWindow Window => _activity.Window ?? throw new InvalidOperationException("Activity.Window must be set."); + public event EventHandler SafeAreaChanged; + public event EventHandler StateChanged; + + public InputPaneState State + { + get => _state; set + { + var oldState = _state; + _state = value; + + if (oldState != value && Build.VERSION.SdkInt <= BuildVersionCodes.Q) + { + var currentRect = OccludedRect; + StateChanged?.Invoke(this, new InputPaneStateEventArgs(value, _previousRect, currentRect, TimeSpan.Zero, null)); + _previousRect = currentRect; + } + } + } public bool DisplayEdgeToEdge { - get => _displayEdgeToEdge; + get => _displayEdgeToEdge; set { _displayEdgeToEdge = value; - var window = _activity.Window; - - if (OperatingSystem.IsAndroidVersionAtLeast(28) && window?.Attributes is { } attributes) + if (OperatingSystem.IsAndroidVersionAtLeast(28) && Window.Attributes is { } attributes) { attributes.LayoutInDisplayCutoutMode = value ? LayoutInDisplayCutoutMode.ShortEdges : LayoutInDisplayCutoutMode.Default; } - if (window is not null) - { - WindowCompat.SetDecorFitsSystemWindows(_activity.Window, !value); - } + WindowCompat.SetDecorFitsSystemWindows(Window, !value); - if(value) + if (value) { - if (window is not null) - { - window.AddFlags(WindowManagerFlags.TranslucentStatus); - window.AddFlags(WindowManagerFlags.TranslucentNavigation); - } + Window.AddFlags(WindowManagerFlags.TranslucentStatus); + Window.AddFlags(WindowManagerFlags.TranslucentNavigation); } else { @@ -57,20 +72,12 @@ namespace Avalonia.Android.Platform } } - public AndroidInsetsManager(AvaloniaMainActivity activity, TopLevelImpl topLevel) + internal AndroidInsetsManager(AvaloniaMainActivity activity, TopLevelImpl topLevel) : base(DispatchModeStop) { _activity = activity; _topLevel = topLevel; - _callback = new InsetsAnimationCallback(WindowInsetsAnimationCompat.Callback.DispatchModeStop); - - _callback.InsetsManager = this; - - if (_activity.Window is { } window) - { - ViewCompat.SetOnApplyWindowInsetsListener(window.DecorView, this); - ViewCompat.SetWindowInsetsAnimationCallback(window.DecorView, _callback); - } + ViewCompat.SetOnApplyWindowInsetsListener(Window.DecorView, this); if (Build.VERSION.SdkInt < BuildVersionCodes.R) { @@ -79,32 +86,48 @@ namespace Avalonia.Android.Platform } DisplayEdgeToEdge = false; + + ViewCompat.SetWindowInsetsAnimationCallback(Window.DecorView, this); } public Thickness SafeAreaPadding { get { - var insets = _activity.Window is { } window ? ViewCompat.GetRootWindowInsets(window.DecorView) : null; + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); if (insets != null) { var renderScaling = _topLevel.RenderScaling; var inset = insets.GetInsets( - (_displayEdgeToEdge ? + _displayEdgeToEdge ? WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars() | - WindowInsetsCompat.Type.DisplayCutout() : - 0) | WindowInsetsCompat.Type.Ime()); - var navBarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()); - var imeInset = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + WindowInsetsCompat.Type.DisplayCutout() : 0); return new Thickness(inset.Left / renderScaling, inset.Top / renderScaling, inset.Right / renderScaling, - (imeInset.Bottom > 0 && ((_usesLegacyLayouts && !_displayEdgeToEdge) || !_usesLegacyLayouts) ? - imeInset.Bottom - (_displayEdgeToEdge ? 0 : navBarInset.Bottom) : - inset.Bottom) / renderScaling); + inset.Bottom / renderScaling); + } + + return default; + } + } + + public Rect OccludedRect + { + get + { + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); + + if (insets != null) + { + var navbarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()).Bottom; + + var height = Math.Max((float)((insets.GetInsets(WindowInsetsCompat.Type.Ime()).Bottom - navbarInset) / _topLevel.RenderScaling), 0); + + return new Rect(0, _topLevel.ClientSize.Height - SafeAreaPadding.Bottom - height, _topLevel.ClientSize.Width, height); } return default; @@ -113,8 +136,16 @@ namespace Avalonia.Android.Platform public WindowInsetsCompat OnApplyWindowInsets(View v, WindowInsetsCompat insets) { - NotifySafeAreaChanged(SafeAreaPadding); insets = ViewCompat.OnApplyWindowInsets(v, insets); + NotifySafeAreaChanged(SafeAreaPadding); + + if (_previousRect == default) + { + _previousRect = OccludedRect; + } + + State = insets.IsVisible(WindowInsetsCompat.Type.Ime()) ? InputPaneState.Open : InputPaneState.Closed; + return insets; } @@ -126,6 +157,12 @@ namespace Avalonia.Android.Platform public void OnGlobalLayout() { NotifySafeAreaChanged(SafeAreaPadding); + + if (_usesLegacyLayouts) + { + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); + State = insets?.IsVisible(WindowInsetsCompat.Type.Ime()) == true ? InputPaneState.Open : InputPaneState.Closed; + } } public SystemBarTheme? SystemBarTheme @@ -134,7 +171,7 @@ namespace Avalonia.Android.Platform { try { - var compat = new WindowInsetsControllerCompat(_activity.Window, _topLevel.View); + var compat = new WindowInsetsControllerCompat(Window, _topLevel.View); return compat.AppearanceLightStatusBars ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; } @@ -152,7 +189,7 @@ namespace Avalonia.Android.Platform return; } - var compat = new WindowInsetsControllerCompat(_activity.Window, _topLevel.View); + var compat = new WindowInsetsControllerCompat(Window, _topLevel.View); if (_isDefaultSystemBarLightTheme == null) { @@ -161,7 +198,7 @@ namespace Avalonia.Android.Platform if (value == null) { - value = (bool)_isDefaultSystemBarLightTheme ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; + value = _isDefaultSystemBarLightTheme.Value ? Controls.Platform.SystemBarTheme.Light : Controls.Platform.SystemBarTheme.Dark; } compat.AppearanceLightStatusBars = value == Controls.Platform.SystemBarTheme.Light; @@ -173,7 +210,7 @@ namespace Avalonia.Android.Platform { get { - if(_activity.Window == null) + if (_activity.Window == null) { return true; } @@ -190,7 +227,7 @@ namespace Avalonia.Android.Platform return; } - var compat = WindowCompat.GetInsetsController(_activity.Window, _topLevel.View); + var compat = WindowCompat.GetInsetsController(Window, _topLevel.View); if (value == null || value.Value) { @@ -210,7 +247,7 @@ namespace Avalonia.Android.Platform public Color? SystemBarColor { - get => _systemBarColor; + get => _systemBarColor; set { _systemBarColor = value; @@ -240,40 +277,48 @@ namespace Avalonia.Android.Platform SystemBarColor = _systemBarColor; } - private class InsetsAnimationCallback : WindowInsetsAnimationCompat.Callback + public override WindowInsetsAnimationCompat.BoundsCompat OnStart(WindowInsetsAnimationCompat animation, WindowInsetsAnimationCompat.BoundsCompat bounds) { - public InsetsAnimationCallback(int dispatchMode) : base(dispatchMode) + if ((animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) { - } - - public AndroidInsetsManager InsetsManager { get; set; } + var insets = ViewCompat.GetRootWindowInsets(Window.DecorView); - public override WindowInsetsCompat OnProgress(WindowInsetsCompat insets, IList runningAnimations) - { - foreach (var anim in runningAnimations) + if (insets != null) { - if ((anim.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) - { - var renderScaling = InsetsManager._topLevel.RenderScaling; - - var inset = insets.GetInsets((InsetsManager.DisplayEdgeToEdge ? WindowInsetsCompat.Type.StatusBars() | WindowInsetsCompat.Type.NavigationBars() | WindowInsetsCompat.Type.DisplayCutout() : 0) | WindowInsetsCompat.Type.Ime()); - var navBarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()); - var imeInset = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + var navbarInset = insets.GetInsets(WindowInsetsCompat.Type.NavigationBars()).Bottom; + var height = Math.Max(0, (float)((bounds.LowerBound.Bottom - navbarInset) / _topLevel.RenderScaling)); + var upperRect = new Rect(0, _topLevel.ClientSize.Height - SafeAreaPadding.Bottom - height, _topLevel.ClientSize.Width, height); + height = Math.Max(0, (float)((bounds.UpperBound.Bottom - navbarInset) / _topLevel.RenderScaling)); + var lowerRect = new Rect(0, _topLevel.ClientSize.Height - SafeAreaPadding.Bottom - height, _topLevel.ClientSize.Width, height); + var duration = TimeSpan.FromMilliseconds(animation.DurationMillis); - var bottomPadding = (imeInset.Bottom > 0 && !InsetsManager.DisplayEdgeToEdge ? imeInset.Bottom - navBarInset.Bottom : inset.Bottom); - bottomPadding = (int)(bottomPadding * anim.InterpolatedFraction); - - var padding = new Thickness(inset.Left / renderScaling, - inset.Top / renderScaling, - inset.Right / renderScaling, - bottomPadding / renderScaling); - InsetsManager?.NotifySafeAreaChanged(padding); - break; - } + bool isOpening = State == InputPaneState.Open; + StateChanged?.Invoke(this, new InputPaneStateEventArgs(State, isOpening ? upperRect : lowerRect, isOpening ? lowerRect : upperRect, duration, new AnimationEasing(animation.Interpolator))); } - return insets; } + + return base.OnStart(animation, bounds); + } + + public override WindowInsetsCompat OnProgress(WindowInsetsCompat insets, IList runningAnimations) + { + return insets; + } + } + + internal sealed class AnimationEasing : Easing + { + private readonly IInterpolator _interpolator; + + public AnimationEasing(IInterpolator interpolator) + { + _interpolator = interpolator; + } + + public override double Ease(double progress) + { + return _interpolator.GetInterpolation((float)progress); } } } diff --git a/src/Android/Avalonia.Android/Platform/AndroidSystemNavigationManager.cs b/src/Android/Avalonia.Android/Platform/AndroidSystemNavigationManager.cs index 880918bebe..ec619fd0f3 100644 --- a/src/Android/Avalonia.Android/Platform/AndroidSystemNavigationManager.cs +++ b/src/Android/Avalonia.Android/Platform/AndroidSystemNavigationManager.cs @@ -6,8 +6,10 @@ using Avalonia.Platform; namespace Avalonia.Android.Platform { - internal class AndroidSystemNavigationManagerImpl : ISystemNavigationManagerImpl + internal class AndroidSystemNavigationManagerImpl : ISystemNavigationManagerImpl, IDisposable { + private readonly IActivityNavigationService? _navigationService; + public event EventHandler? BackRequested; public AndroidSystemNavigationManagerImpl(IActivityNavigationService? navigationService) @@ -16,6 +18,7 @@ namespace Avalonia.Android.Platform { navigationService.BackRequested += OnBackRequested; } + _navigationService = navigationService; } private void OnBackRequested(object? sender, AndroidBackRequestedEventArgs e) @@ -26,5 +29,13 @@ namespace Avalonia.Android.Platform e.Handled = routedEventArgs.Handled; } + + public void Dispose() + { + if (_navigationService != null) + { + _navigationService.BackRequested -= OnBackRequested; + } + } } } diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index d1ef928367..e9f9050af1 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -45,7 +45,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly AndroidInputMethod _textInputMethod; private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider _storageProvider; - private readonly ISystemNavigationManagerImpl _systemNavigationManager; + private readonly AndroidSystemNavigationManagerImpl _systemNavigationManager; private readonly AndroidInsetsManager _insetsManager; private readonly ClipboardImpl _clipboard; private ViewImpl _view; @@ -155,6 +155,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform public virtual void Dispose() { + _systemNavigationManager.Dispose(); _view.Dispose(); _view = null; } @@ -395,7 +396,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform return _nativeControlHost; } - if (featureType == typeof(IInsetsManager)) + if (featureType == typeof(IInsetsManager) || featureType == typeof(IInputPane)) { return _insetsManager; } diff --git a/src/Avalonia.Base/Animation/Animatable.cs b/src/Avalonia.Base/Animation/Animatable.cs index d67a9501da..30c1f78c36 100644 --- a/src/Avalonia.Base/Animation/Animatable.cs +++ b/src/Avalonia.Base/Animation/Animatable.cs @@ -17,8 +17,8 @@ namespace Avalonia.Animation /// /// Defines the property. /// - internal static readonly StyledProperty ClockProperty = - AvaloniaProperty.Register(nameof(Clock), inherits: true); + internal static readonly StyledProperty ClockProperty = + AvaloniaProperty.Register(nameof(Clock), inherits: true); /// /// Defines the property. @@ -36,7 +36,7 @@ namespace Avalonia.Animation /// /// Gets or sets the clock which controls the animations on the control. /// - internal IClock Clock + internal IClock? Clock { get => GetValue(ClockProperty); set => SetValue(ClockProperty, value); diff --git a/src/Avalonia.Base/Animation/Animation.cs b/src/Avalonia.Base/Animation/Animation.cs index f584cad951..7e139c5ae6 100644 --- a/src/Avalonia.Base/Animation/Animation.cs +++ b/src/Avalonia.Base/Animation/Animation.cs @@ -234,6 +234,8 @@ namespace Avalonia.Animation } } + animatorKeyFrames.Sort(static (x, y) => x.Cue.CueValue.CompareTo(y.Cue.CueValue)); + var newAnimatorInstances = new List(); foreach (var handler in handlerList) @@ -247,9 +249,22 @@ namespace Avalonia.Animation { var animator = newAnimatorInstances.First(a => a.GetType() == keyframe.AnimatorType && a.Property == keyframe.Property); + + if (animator.Count == 0 && FillMode is FillMode.Backward or FillMode.Both) + keyframe.FillBefore = true; + animator.Add(keyframe); } + if (FillMode is FillMode.Forward or FillMode.Both) + { + foreach (var newAnimatorInstance in newAnimatorInstances) + { + if (newAnimatorInstance.Count > 0) + newAnimatorInstance[newAnimatorInstance.Count - 1].FillAfter = true; + } + } + return (newAnimatorInstances, subscriptions); } diff --git a/src/Avalonia.Base/Animation/AnimatorKeyFrame.cs b/src/Avalonia.Base/Animation/AnimatorKeyFrame.cs index 4db58fac5c..190524402a 100644 --- a/src/Avalonia.Base/Animation/AnimatorKeyFrame.cs +++ b/src/Avalonia.Base/Animation/AnimatorKeyFrame.cs @@ -16,19 +16,6 @@ namespace Avalonia.Animation public static readonly DirectProperty ValueProperty = AvaloniaProperty.RegisterDirect(nameof(Value), k => k.Value, (k, v) => k.Value = v); - public AnimatorKeyFrame() - { - - } - - public AnimatorKeyFrame(Type? animatorType, Func? animatorFactory, Cue cue) - { - AnimatorType = animatorType; - AnimatorFactory = animatorFactory; - Cue = cue; - KeySpline = null; - } - public AnimatorKeyFrame(Type? animatorType, Func? animatorFactory, Cue cue, KeySpline? keySpline) { AnimatorType = animatorType; @@ -37,11 +24,12 @@ namespace Avalonia.Animation KeySpline = keySpline; } - internal bool isNeutral; public Type? AnimatorType { get; } public Func? AnimatorFactory { get; } public Cue Cue { get; } public KeySpline? KeySpline { get; } + public bool FillBefore { get; set; } + public bool FillAfter { get; set; } public AvaloniaProperty? Property { get; private set; } private object? _value; diff --git a/src/Avalonia.Base/Animation/Animators/Animator`1.cs b/src/Avalonia.Base/Animation/Animators/Animator`1.cs index f93067d642..8a4469d020 100644 --- a/src/Avalonia.Base/Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Base/Animation/Animators/Animator`1.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; -using System.Linq; -using Avalonia.Animation.Utils; +using System.Diagnostics; using Avalonia.Collections; using Avalonia.Data; using Avalonia.Reactive; @@ -13,94 +11,72 @@ namespace Avalonia.Animation.Animators /// internal abstract class Animator : AvaloniaList, IAnimator { - /// - /// List of type-converted keyframes. - /// - private readonly List _convertedKeyframes = new List(); - - private bool _isVerifiedAndConverted; - /// /// Gets or sets the target property for the keyframe. /// public AvaloniaProperty? Property { get; set; } - public Animator() - { - // Invalidate keyframes when changed. - this.CollectionChanged += delegate { _isVerifiedAndConverted = false; }; - } - /// public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete) { - if (!_isVerifiedAndConverted) - VerifyConvertKeyFrames(); - var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete); return new CompositeDisposable(match.Subscribe(subject), subject); } protected T InterpolationHandler(double animationTime, T neutralValue) { - AnimatorKeyFrame firstKeyframe, lastKeyframe; + if (Count == 0) + return neutralValue; + + var (beforeKeyFrame, afterKeyFrame) = FindKeyFrames(animationTime); - int kvCount = _convertedKeyframes.Count; - if (kvCount > 2) + double beforeTime, afterTime; + T beforeValue, afterValue; + + if (beforeKeyFrame is null) { - if (animationTime <= 0.0) - { - firstKeyframe = _convertedKeyframes[0]; - lastKeyframe = _convertedKeyframes[1]; - } - else if (animationTime >= 1.0) - { - firstKeyframe = _convertedKeyframes[_convertedKeyframes.Count - 2]; - lastKeyframe = _convertedKeyframes[_convertedKeyframes.Count - 1]; - } - else - { - int index = FindClosestBeforeKeyFrame(animationTime); - firstKeyframe = _convertedKeyframes[index]; - lastKeyframe = _convertedKeyframes[index + 1]; - } + beforeTime = 0.0; + beforeValue = afterKeyFrame is { FillBefore: true, Value: T fillValue } ? fillValue : neutralValue; } else { - firstKeyframe = _convertedKeyframes[0]; - lastKeyframe = _convertedKeyframes[1]; + beforeTime = beforeKeyFrame.Cue.CueValue; + beforeValue = beforeKeyFrame.Value is T value ? value : neutralValue; } - double t0 = firstKeyframe.Cue.CueValue; - double t1 = lastKeyframe.Cue.CueValue; - - double progress = (animationTime - t0) / (t1 - t0); - - T oldValue, newValue; - - if (!firstKeyframe.isNeutral && firstKeyframe.Value is T firstKeyframeValue) - oldValue = firstKeyframeValue; + if (afterKeyFrame is null) + { + afterTime = 1.0; + afterValue = beforeKeyFrame is { FillAfter: true, Value: T fillValue } ? fillValue : neutralValue; + } else - oldValue = neutralValue; + { + afterTime = afterKeyFrame.Cue.CueValue; + afterValue = afterKeyFrame.Value is T value ? value : neutralValue; + } - if (!lastKeyframe.isNeutral && lastKeyframe.Value is T lastKeyframeValue) - newValue = lastKeyframeValue; - else - newValue = neutralValue; + var progress = (animationTime - beforeTime) / (afterTime - beforeTime); - if (lastKeyframe.KeySpline != null) - progress = lastKeyframe.KeySpline.GetSplineProgress(progress); + if (afterKeyFrame?.KeySpline is { } keySpline) + progress = keySpline.GetSplineProgress(progress); - return Interpolate(progress, oldValue, newValue); + return Interpolate(progress, beforeValue, afterValue); } - private int FindClosestBeforeKeyFrame(double time) + private (AnimatorKeyFrame? Before, AnimatorKeyFrame? After) FindKeyFrames(double time) { - for (int i = 0; i < _convertedKeyframes.Count; i++) - if (_convertedKeyframes[i].Cue.CueValue > time) - return i - 1; + Debug.Assert(Count >= 1); - throw new Exception("Index time is out of keyframe time range."); + for (var i = 0; i < Count; i++) + { + var keyFrame = this[i]; + var keyFrameTime = keyFrame.Cue.CueValue; + + if (time < keyFrameTime || keyFrameTime == 1.0) + return (i > 0 ? this[i - 1] : null, keyFrame); + } + + return (this[Count - 1], null); } public virtual IDisposable BindAnimation(Animatable control, IObservable instance) @@ -123,7 +99,7 @@ namespace Avalonia.Animation.Animators clock ?? control.Clock ?? Clock.GlobalClock, onComplete, InterpolationHandler); - + return BindAnimation(control, instance); } @@ -131,52 +107,5 @@ namespace Avalonia.Animation.Animators /// Interpolates in-between two key values given the desired progress time. /// public abstract T Interpolate(double progress, T oldValue, T newValue); - - private void VerifyConvertKeyFrames() - { - foreach (AnimatorKeyFrame keyframe in this) - { - _convertedKeyframes.Add(keyframe); - } - - AddNeutralKeyFramesIfNeeded(); - - _isVerifiedAndConverted = true; - } - - private void AddNeutralKeyFramesIfNeeded() - { - bool hasStartKey, hasEndKey; - hasStartKey = hasEndKey = false; - - // Check if there's start and end keyframes. - foreach (var frame in _convertedKeyframes) - { - if (frame.Cue.CueValue == 0.0d) - { - hasStartKey = true; - } - else if (frame.Cue.CueValue == 1.0d) - { - hasEndKey = true; - } - } - - if (!hasStartKey || !hasEndKey) - AddNeutralKeyFrames(hasStartKey, hasEndKey); - } - - private void AddNeutralKeyFrames(bool hasStartKey, bool hasEndKey) - { - if (!hasStartKey) - { - _convertedKeyframes.Insert(0, new AnimatorKeyFrame(null, null, new Cue(0.0d)) { Value = default(T), isNeutral = true }); - } - - if (!hasEndKey) - { - _convertedKeyframes.Add(new AnimatorKeyFrame(null, null, new Cue(1.0d)) { Value = default(T), isNeutral = true }); - } - } } } diff --git a/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs b/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs index 96617b1732..c81be67060 100644 --- a/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/BaseBrushAnimator.cs @@ -86,14 +86,18 @@ namespace Avalonia.Animation.Animators { gradientAnimator.Add(new AnimatorKeyFrame(typeof(GradientBrushAnimator), () => new GradientBrushAnimator(), keyframe.Cue, keyframe.KeySpline) { - Value = GradientBrushAnimator.ConvertSolidColorBrushToGradient(firstGradient, solidColorBrush) + Value = GradientBrushAnimator.ConvertSolidColorBrushToGradient(firstGradient, solidColorBrush), + FillBefore = keyframe.FillBefore, + FillAfter = keyframe.FillAfter }); } else if (keyframe.Value is IGradientBrush) { gradientAnimator.Add(new AnimatorKeyFrame(typeof(GradientBrushAnimator), () => new GradientBrushAnimator(), keyframe.Cue, keyframe.KeySpline) { - Value = keyframe.Value + Value = keyframe.Value, + FillBefore = keyframe.FillBefore, + FillAfter = keyframe.FillAfter }); } else @@ -118,7 +122,9 @@ namespace Avalonia.Animation.Animators { solidColorBrushAnimator.Add(new AnimatorKeyFrame(typeof(ISolidColorBrushAnimator), () => new ISolidColorBrushAnimator(), keyframe.Cue, keyframe.KeySpline) { - Value = keyframe.Value + Value = keyframe.Value, + FillBefore = keyframe.FillBefore, + FillAfter = keyframe.FillAfter }); } else @@ -149,7 +155,9 @@ namespace Avalonia.Animation.Animators { animator.Add(new AnimatorKeyFrame(animatorType, animatorFactory, keyframe.Cue, keyframe.KeySpline) { - Value = keyframe.Value + Value = keyframe.Value, + FillBefore = keyframe.FillBefore, + FillAfter = keyframe.FillAfter }); } diff --git a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs index 7134a42666..347ba35a41 100644 --- a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs +++ b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs @@ -41,14 +41,16 @@ namespace Avalonia.Input _lastActivePointerDevice = pointerDevice; } - if (args.Type is RawPointerEventType.LeaveWindow or RawPointerEventType.NonClientLeftButtonDown - or RawPointerEventType.TouchCancel or RawPointerEventType.TouchEnd - && _currentPointer is var (lastPointer, lastPosition)) + if (args.Type is RawPointerEventType.LeaveWindow or RawPointerEventType.NonClientLeftButtonDown + or RawPointerEventType.TouchCancel or RawPointerEventType.TouchEnd) { - _currentPointer = null; - ClearPointerOver(lastPointer, args.Root, 0, PointToClient(args.Root, lastPosition), - new PointerPointProperties(args.InputModifiers, args.Type.ToUpdateKind()), - args.InputModifiers.ToKeyModifiers()); + if (_currentPointer is var (lastPointer, lastPosition)) + { + _currentPointer = null; + ClearPointerOver(lastPointer, args.Root, 0, PointToClient(args.Root, lastPosition), + new PointerPointProperties(args.InputModifiers, args.Type.ToUpdateKind()), + args.InputModifiers.ToKeyModifiers()); + } } else if (args.Type is RawPointerEventType.TouchBegin or RawPointerEventType.TouchUpdate && args.Root is Visual visual) { diff --git a/src/Avalonia.Base/Media/DrawingContext.cs b/src/Avalonia.Base/Media/DrawingContext.cs index 5d258cb040..bd2c43878d 100644 --- a/src/Avalonia.Base/Media/DrawingContext.cs +++ b/src/Avalonia.Base/Media/DrawingContext.cs @@ -123,7 +123,7 @@ namespace Avalonia.Media /// Box shadow effect parameters /// /// The brush and the pen can both be null. If the brush is null, then no fill is performed. - /// If the pen is null, then no stoke is performed. If both the pen and the brush are null, then the drawing is not visible. + /// If the pen is null, then no stroke is performed. If both the pen and the brush are null, then the drawing is not visible. /// public void DrawRectangle(IBrush? brush, IPen? pen, Rect rect, double radiusX = 0, double radiusY = 0, diff --git a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs index cdf6aa6b63..e2c4cc096c 100644 --- a/src/Avalonia.Base/Media/Effects/EffectAnimator.cs +++ b/src/Avalonia.Base/Media/Effects/EffectAnimator.cs @@ -38,7 +38,9 @@ internal class EffectAnimator : Animator createdAnimator.Add(new AnimatorKeyFrame(typeof(TAnimator), () => new TAnimator(), keyFrame.Cue, keyFrame.KeySpline) { - Value = keyFrame.Value + Value = keyFrame.Value, + FillBefore = keyFrame.FillBefore, + FillAfter = keyFrame.FillAfter }); } else diff --git a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs index 2f8c4ad263..a4fc1edd14 100644 --- a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs +++ b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs @@ -48,7 +48,7 @@ namespace Avalonia.Media.TextFormatting /// /// The created text style run. /// - private static ValueSpan CreateTextStyleRun(ReadOnlySpan text, int firstTextSourceIndex, + internal static ValueSpan CreateTextStyleRun(ReadOnlySpan text, int firstTextSourceIndex, TextRunProperties defaultProperties, IReadOnlyList>? textModifier) { if (textModifier == null || textModifier.Count == 0) diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs index efaab511ce..d9e6d6486d 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs @@ -1245,8 +1245,9 @@ namespace Avalonia.Media.TextFormatting { var textMetrics = textRun.TextMetrics; var glyphRun = textRun.GlyphRun; + var runBounds = glyphRun.InkBounds.WithX(widthIncludingWhitespace + glyphRun.InkBounds.X); - bounds = bounds.Union(glyphRun.InkBounds); + bounds = bounds.Union(runBounds); if (fontRenderingEmSize < textMetrics.FontRenderingEmSize) { diff --git a/src/Avalonia.Base/PropertyStore/BindingEntryBase.cs b/src/Avalonia.Base/PropertyStore/BindingEntryBase.cs index ee0a7a3740..dc2f19d389 100644 --- a/src/Avalonia.Base/PropertyStore/BindingEntryBase.cs +++ b/src/Avalonia.Base/PropertyStore/BindingEntryBase.cs @@ -49,15 +49,6 @@ namespace Avalonia.PropertyStore _uncommon = new() { _hasDataValidation = true }; } - public bool HasValue - { - get - { - Start(produceValue: false); - return _hasValue; - } - } - public bool IsSubscribed => _subscription is not null; public AvaloniaProperty Property { get; } AvaloniaProperty IValueEntry.Property => Property; @@ -70,6 +61,12 @@ namespace Avalonia.PropertyStore BindingCompleted(); } + public bool HasValue() + { + Start(produceValue: false); + return _hasValue; + } + public TValue GetValue() { Start(produceValue: false); diff --git a/src/Avalonia.Base/PropertyStore/IValueEntry.cs b/src/Avalonia.Base/PropertyStore/IValueEntry.cs index 5898bef491..7ac7e83276 100644 --- a/src/Avalonia.Base/PropertyStore/IValueEntry.cs +++ b/src/Avalonia.Base/PropertyStore/IValueEntry.cs @@ -8,13 +8,16 @@ namespace Avalonia.PropertyStore /// internal interface IValueEntry { - bool HasValue { get; } - /// /// Gets the property that this value applies to. /// AvaloniaProperty Property { get; } + /// + /// Checks whether the entry has a value, starting the entry if necessary. + /// + bool HasValue(); + /// /// Gets the value associated with the entry. /// diff --git a/src/Avalonia.Base/PropertyStore/ImmediateValueEntry.cs b/src/Avalonia.Base/PropertyStore/ImmediateValueEntry.cs index 16b96eff5d..2d136fb1e5 100644 --- a/src/Avalonia.Base/PropertyStore/ImmediateValueEntry.cs +++ b/src/Avalonia.Base/PropertyStore/ImmediateValueEntry.cs @@ -19,13 +19,13 @@ namespace Avalonia.PropertyStore } public StyledProperty Property { get; } - public bool HasValue => true; AvaloniaProperty IValueEntry.Property => Property; public void Unsubscribe() { } public void Dispose() => _owner.OnEntryDisposed(this); + bool IValueEntry.HasValue() => true; object? IValueEntry.GetValue() => _value; T IValueEntry.GetValue() => _value; diff --git a/src/Avalonia.Base/PropertyStore/ValueFrame.cs b/src/Avalonia.Base/PropertyStore/ValueFrame.cs index 4d74ddcbb1..0b51189a15 100644 --- a/src/Avalonia.Base/PropertyStore/ValueFrame.cs +++ b/src/Avalonia.Base/PropertyStore/ValueFrame.cs @@ -28,7 +28,7 @@ namespace Avalonia.PropertyStore } public int EntryCount => _index.Count; - public bool IsActive => GetIsActive(out _); + public bool IsActive() => GetIsActive(out _); public ValueStore? Owner => !_isShared ? _owner : throw new AvaloniaInternalException("Cannot get owner for shared ValueFrame"); public BindingPriority Priority { get; } diff --git a/src/Avalonia.Base/PropertyStore/ValueStore.cs b/src/Avalonia.Base/PropertyStore/ValueStore.cs index e8f358cb7f..7008f841f4 100644 --- a/src/Avalonia.Base/PropertyStore/ValueStore.cs +++ b/src/Avalonia.Base/PropertyStore/ValueStore.cs @@ -983,7 +983,7 @@ namespace Avalonia.PropertyStore // evaluated last as it can cause bindings to be subscribed. if (foundEntry && HasHigherPriority(entry!, priority, current, changedValueEntry) && - entry!.HasValue) + entry!.HasValue()) { if (current is not null) { @@ -1051,7 +1051,7 @@ namespace Avalonia.PropertyStore { var frame = _frames[i]; - if (!frame.IsActive) + if (!frame.IsActive()) continue; var priority = frame.Priority; @@ -1067,7 +1067,7 @@ namespace Avalonia.PropertyStore if (!HasHigherPriority(entry, priority, effectiveValue, changedValueEntry)) continue; - if (!entry.HasValue) + if (!entry.HasValue()) continue; if (effectiveValue is not null) diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs index df6b478e83..c6a3859e3d 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs @@ -1,5 +1,4 @@ using Avalonia.Media; -using Avalonia.Platform; namespace Avalonia.Rendering.Composition.Drawing.Nodes; @@ -7,24 +6,41 @@ class RenderDataRectangleNode : RenderDataBrushAndPenNode { public RoundedRect Rect { get; set; } public BoxShadows BoxShadows { get; set; } - + public override bool HitTest(Point p) { - if (ServerBrush != null) // it's safe to check for null + var strokeThicknessAdjustment = (ClientPen?.Thickness / 2) ?? 0; + + if (Rect.IsRounded) { - var rect = Rect.Rect.Inflate((ClientPen?.Thickness / 2) ?? 0); - return rect.ContainsExclusive(p); + var outerRoundedRect = Rect.Inflate(strokeThicknessAdjustment, strokeThicknessAdjustment); + if (outerRoundedRect.ContainsExclusive(p)) + { + if (ServerBrush != null) // it's safe to check for null + return true; + + var innerRoundedRect = Rect.Deflate(strokeThicknessAdjustment, strokeThicknessAdjustment); + return !innerRoundedRect.ContainsExclusive(p); + } } else { - var borderRect = Rect.Rect.Inflate((ClientPen?.Thickness / 2) ?? 0); - var emptyRect = Rect.Rect.Deflate((ClientPen?.Thickness / 2) ?? 0); - return borderRect.ContainsExclusive(p) && !emptyRect.ContainsExclusive(p); + var outerRect = Rect.Rect.Inflate(strokeThicknessAdjustment); + if (outerRect.ContainsExclusive(p)) + { + if (ServerBrush != null) // it's safe to check for null + return true; + + var innerRect = Rect.Rect.Deflate(strokeThicknessAdjustment); + return !innerRect.ContainsExclusive(p); + } } + + return false; } public override void Invoke(ref RenderDataNodeRenderContext context) => context.Context.DrawRectangle(ServerBrush, ServerPen, Rect, BoxShadows); public override Rect? Bounds => BoxShadows.TransformBounds(Rect.Rect).Inflate((ServerPen?.Thickness ?? 0) / 2); -} \ No newline at end of file +} diff --git a/src/Avalonia.Base/RoundedRect.cs b/src/Avalonia.Base/RoundedRect.cs index 4c6f46ffe0..0b23a8c0ca 100644 --- a/src/Avalonia.Base/RoundedRect.cs +++ b/src/Avalonia.Base/RoundedRect.cs @@ -150,5 +150,64 @@ namespace Avalonia /// For now it's internal to keep some loud community members happy about the API being pretty /// internal bool IsEmpty() => this == default; + + private static bool IsOutsideCorner(double dx, double dy, double radius) + { + return (dx < 0) && (dy < 0) && (dx * dx + dy * dy > radius * radius); + } + + /// + /// Determines whether a point is in the bounds of the rounded rectangle, exclusive of the + /// rounded rectangle's bottom/right edge. + /// + /// The point. + /// true if the point is in the bounds of the rounded rectangle; otherwise false. + public bool ContainsExclusive(Point p) + { + // Do a simple rectangular bounds check first + if (!Rect.ContainsExclusive(p)) + return false; + + // If any radii totals exceed available bounds, determine a scale factor that needs to be applied + var scaleFactor = 1.0; + if (Rect.Width > 0) + { + var radiiWidth = Math.Max(RadiiTopLeft.X + RadiiTopRight.X, RadiiBottomLeft.X + RadiiBottomRight.X); + if (radiiWidth > Rect.Width) + scaleFactor = Math.Min(scaleFactor, Rect.Width / radiiWidth); + } + if (Rect.Height > 0) + { + var radiiHeight = Math.Max(RadiiTopLeft.Y + RadiiBottomLeft.Y, RadiiTopRight.Y + RadiiBottomRight.Y); + if (radiiHeight > Rect.Height) + scaleFactor = Math.Min(scaleFactor, Rect.Height / radiiHeight); + } + + // Before corner hit-testing, make the point relative to the bounds' upper-left + p = new Point(p.X - Rect.X, p.Y - Rect.Y); + + // Top-left corner + var radius = Math.Min(RadiiTopLeft.X, RadiiTopLeft.Y) * scaleFactor; + if (IsOutsideCorner(p.X - radius, p.Y - radius, radius)) + return false; + + // Top-right corner + radius = Math.Min(RadiiTopRight.X, RadiiTopRight.Y) * scaleFactor; + if (IsOutsideCorner(Rect.Width - radius - p.X, p.Y - radius, radius)) + return false; + + // Bottom-right corner + radius = Math.Min(RadiiBottomRight.X, RadiiBottomRight.Y) * scaleFactor; + if (IsOutsideCorner(Rect.Width - radius - p.X, Rect.Height - radius - p.Y, radius)) + return false; + + // Bottom-left corner + radius = Math.Min(RadiiBottomLeft.X, RadiiBottomLeft.Y) * scaleFactor; + if (IsOutsideCorner(p.X - radius, Rect.Height - radius - p.Y, radius)) + return false; + + return true; + } + } } diff --git a/src/Avalonia.Base/Styling/Activators/NthChildActivator.cs b/src/Avalonia.Base/Styling/Activators/NthChildActivator.cs index 8fe0bb2537..888a92fcdf 100644 --- a/src/Avalonia.Base/Styling/Activators/NthChildActivator.cs +++ b/src/Avalonia.Base/Styling/Activators/NthChildActivator.cs @@ -1,4 +1,5 @@ -using Avalonia.LogicalTree; +using System.Collections.Generic; +using Avalonia.LogicalTree; namespace Avalonia.Styling.Activators { @@ -12,7 +13,7 @@ namespace Avalonia.Styling.Activators private readonly int _step; private readonly int _offset; private readonly bool _reversed; - private int? _index; + private int _index = -1; public NthChildActivator( ILogical control, @@ -28,7 +29,7 @@ namespace Avalonia.Styling.Activators protected override bool EvaluateIsActive() { - var index = _index ?? _provider.GetChildIndex(_control); + var index = _index >= 0 ? _index : _provider.GetChildIndex(_control); return NthChildSelector.Evaluate(index, _provider, _step, _offset, _reversed).IsMatch; } @@ -50,26 +51,25 @@ namespace Avalonia.Styling.Activators // 3. We're a reversed (nth-last-child) selector and total count has changed switch (e.Action) { - // We're using the _index field to pass the index of the child to EvaluateIsActive - // *only* when the active state is re-evaluated via this event handler. The docs - // for EvaluateIsActive say: + // The docs for EvaluateIsActive say: // // > This method should read directly from its inputs and not rely on any // > subscriptions to fire in order to be up-to-date. // // Which is good advice in general, however in this case we need to break the rule - // and use the value from the event subscription instead of calling + // and use the value from the event subscription where possible instead of calling // IChildIndexProvider.GetChildIndex. This is because this event can be fired during - // the process of realizing an element of a virtualized list; in this case calling - // GetChildIndex may not return the correct index as the element isn't yet realized. + // the process of realizing an element of a virtualized list; in this case there may + // be more than one `nth-child` style on a the list item and when the other is + // re-evaluated calling GetChildIndex may not return the correct index as the element + // isn't yet realized. case ChildIndexChangedAction.ChildIndexChanged when e.Child == _control: - _index = e.Index; + _index = e.Index >= 0 ? e.Index : _provider.GetChildIndex(_control); ReevaluateIsActive(); - _index = null; break; case ChildIndexChangedAction.ChildIndexesReset: case ChildIndexChangedAction.TotalCountChanged when _reversed: - _index = null; + _index = _provider.GetChildIndex(_control); ReevaluateIsActive(); break; } diff --git a/src/Avalonia.Base/Styling/PropertySetterTemplateInstance.cs b/src/Avalonia.Base/Styling/PropertySetterTemplateInstance.cs index 7604c26244..d9a2f55da9 100644 --- a/src/Avalonia.Base/Styling/PropertySetterTemplateInstance.cs +++ b/src/Avalonia.Base/Styling/PropertySetterTemplateInstance.cs @@ -15,9 +15,9 @@ namespace Avalonia.Styling Property = property; } - public bool HasValue => true; public AvaloniaProperty Property { get; } + public bool HasValue() => true; public object? GetValue() => _value ??= _template.Build(); bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error) diff --git a/src/Avalonia.Base/Styling/Setter.cs b/src/Avalonia.Base/Styling/Setter.cs index a59dd0dba8..8bfda18d6d 100644 --- a/src/Avalonia.Base/Styling/Setter.cs +++ b/src/Avalonia.Base/Styling/Setter.cs @@ -59,7 +59,6 @@ namespace Avalonia.Styling } } - bool IValueEntry.HasValue => true; AvaloniaProperty IValueEntry.Property => EnsureProperty(); public override string ToString() => $"Setter: {Property} = {Value}"; @@ -91,6 +90,7 @@ namespace Avalonia.Styling return this; } + bool IValueEntry.HasValue() => true; object? IValueEntry.GetValue() => Value; bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error) diff --git a/src/Avalonia.Controls.DataGrid/Collections/DataGridCollectionView.cs b/src/Avalonia.Controls.DataGrid/Collections/DataGridCollectionView.cs index d21a7bdb3f..8934b08b98 100644 --- a/src/Avalonia.Controls.DataGrid/Collections/DataGridCollectionView.cs +++ b/src/Avalonia.Controls.DataGrid/Collections/DataGridCollectionView.cs @@ -1153,15 +1153,23 @@ namespace Avalonia.Collections get { return GetItemAt(index); } } - bool IList.IsFixedSize => false; - bool IList.IsReadOnly => true; + bool IList.IsFixedSize => SourceList?.IsFixedSize ?? true; + bool IList.IsReadOnly => SourceList?.IsReadOnly ?? true; bool ICollection.IsSynchronized => false; object ICollection.SyncRoot => this; object IList.this[int index] { get => this[index]; - set => throw new NotSupportedException(); + set + { + SourceList[index] = value; + if (SourceList is not INotifyCollectionChanged) + { + // TODO: implement Replace + ProcessCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset, value)); + } + } } /// @@ -3992,9 +4000,36 @@ namespace Avalonia.Collections } } - int IList.Add(object value) => throw new NotSupportedException(); - void IList.Clear() => throw new NotSupportedException(); - void IList.Insert(int index, object value) => throw new NotSupportedException(); + int IList.Add(object value) + { + var index = SourceList.Add(value); + if (SourceList is not INotifyCollectionChanged) + { + ProcessCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, value)); + } + return index; + } + + void IList.Clear() + { + SourceList.Clear(); + if (SourceList is not INotifyCollectionChanged) + { + ProcessCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } + + void IList.Insert(int index, object value) + { + SourceList.Insert(index, value); + if (SourceList is not INotifyCollectionChanged) + { + // TODO: implement Insert + ProcessCollectionChanged( + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset, value)); + } + } void ICollection.CopyTo(Array array, int index) => InternalList.CopyTo(array, index); /// diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index 88270ee5cc..e4573c3759 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -1828,12 +1828,6 @@ namespace Avalonia.Controls private set; } - internal bool UpdatedStateOnMouseLeftButtonDown - { - get; - set; - } - /// /// Indicates whether or not to use star-sizing logic. If the DataGrid has infinite available space, /// then star sizing doesn't make sense. In this case, all star columns grow to a predefined size of diff --git a/src/Avalonia.Controls.DataGrid/DataGridCell.cs b/src/Avalonia.Controls.DataGrid/DataGridCell.cs index 599bea056b..2f5f2c25e0 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridCell.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridCell.cs @@ -169,9 +169,13 @@ namespace Avalonia.Controls return; } OwningGrid.OnCellPointerPressed(new DataGridCellPointerPressedEventArgs(this, OwningRow, OwningColumn, e)); + if (e.Handled) + { + return; + } if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - if (!e.Handled && OwningGrid.IsTabStop) + if (OwningGrid.IsTabStop) { OwningGrid.Focus(); } @@ -185,13 +189,11 @@ namespace Avalonia.Controls { e.Handled = handled; } - - OwningGrid.UpdatedStateOnMouseLeftButtonDown = true; } } else if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed) { - if (!e.Handled && OwningGrid.IsTabStop) + if (OwningGrid.IsTabStop) { OwningGrid.Focus(); } diff --git a/src/Avalonia.Controls.DataGrid/DataGridDataConnection.cs b/src/Avalonia.Controls.DataGrid/DataGridDataConnection.cs index fc9aac0ab8..835bb566fe 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridDataConnection.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridDataConnection.cs @@ -349,15 +349,15 @@ namespace Avalonia.Controls { Debug.Assert(index >= 0); - IList list = List; - if (list != null) + if (DataSource is DataGridCollectionView collectionView) { - return (index < list.Count) ? list[index] : null; + return (index < collectionView.Count) ? collectionView.GetItemAt(index) : null; } - if (DataSource is DataGridCollectionView collectionView) + IList list = List; + if (list != null) { - return (index < collectionView.Count) ? collectionView.GetItemAt(index) : null; + return (index < list.Count) ? list[index] : null; } IEnumerable enumerable = DataSource; @@ -419,15 +419,15 @@ namespace Avalonia.Controls public int IndexOf(object dataItem) { - IList list = List; - if (list != null) + if (DataSource is DataGridCollectionView cv) { - return list.IndexOf(dataItem); + return cv.IndexOf(dataItem); } - if (DataSource is DataGridCollectionView cv) + IList list = List; + if (list != null) { - return cv.IndexOf(dataItem); + return list.IndexOf(dataItem); } IEnumerable enumerable = DataSource; diff --git a/src/Avalonia.Controls.DataGrid/DataGridRow.cs b/src/Avalonia.Controls.DataGrid/DataGridRow.cs index dfda7d6e4f..48d6ca7f44 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridRow.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridRow.cs @@ -771,14 +771,6 @@ namespace Avalonia.Controls if (OwningGrid != null) { OwningGrid.IsDoubleClickRecordsClickOnCall(this); - if (OwningGrid.UpdatedStateOnMouseLeftButtonDown) - { - OwningGrid.UpdatedStateOnMouseLeftButtonDown = false; - } - else - { - e.Handled = OwningGrid.UpdateStateOnMouseLeftButtonDown(e, -1, Slot, false); - } } } diff --git a/src/Avalonia.Controls.DataGrid/DataGridRowHeader.cs b/src/Avalonia.Controls.DataGrid/DataGridRowHeader.cs index c42d21d126..a480a7af15 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridRowHeader.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridRowHeader.cs @@ -195,7 +195,6 @@ namespace Avalonia.Controls.Primitives Debug.Assert(sender is DataGridRowHeader); Debug.Assert(sender == this); e.Handled = OwningGrid.UpdateStateOnMouseLeftButtonDown(e, -1, Slot, false); - OwningGrid.UpdatedStateOnMouseLeftButtonDown = true; } } else if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed) diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs index 47a0c531ba..8a1d38f88a 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs @@ -15,6 +15,15 @@ namespace Avalonia.Controls { public partial class AutoCompleteBox { + /// + /// Defines see property. + /// + public static readonly StyledProperty CaretIndexProperty = + TextBox.CaretIndexProperty.AddOwner(new( + defaultValue: 0, + defaultBindingMode:BindingMode.TwoWay, + coerce: TextBox.CoerceCaretIndex)); + public static readonly StyledProperty WatermarkProperty = TextBox.WatermarkProperty.AddOwner(); @@ -158,6 +167,15 @@ namespace Avalonia.Controls AvaloniaProperty.Register>>?>( nameof(AsyncPopulator)); + /// + /// Gets or sets the caret index + /// + public int CaretIndex + { + get => GetValue(CaretIndexProperty); + set => SetValue(CaretIndexProperty, value); + } + /// /// Gets or sets the minimum number of characters required to be entered /// in the text box before the displays possible matches. diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index a42ac48864..366c145356 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -528,6 +528,17 @@ namespace Avalonia.Controls _itemsPresenter = e.NameScope.Find("PART_ItemsPresenter"); } + protected override void OnGotFocus(GotFocusEventArgs e) + { + base.OnGotFocus(e); + + // If the focus is coming from a child control, set the tab once active element to + // the focused control. This ensures that tabbing back into the control will focus + // the last focused control when TabNavigationMode == Once. + if (e.Source != this && e.Source is IInputElement ie) + KeyboardNavigation.SetTabOnceActiveElement(this, ie); + } + /// /// Handles directional navigation within the . /// diff --git a/src/Avalonia.Controls/ListBoxItem.cs b/src/Avalonia.Controls/ListBoxItem.cs index aa95511524..5ee4854554 100644 --- a/src/Avalonia.Controls/ListBoxItem.cs +++ b/src/Avalonia.Controls/ListBoxItem.cs @@ -104,7 +104,11 @@ namespace Avalonia.Controls // As we only update selection from touch/pen on pointer release, we need to raise // the pointer event on the owner to trigger a commit. if (e.Pointer.Type != PointerType.Mouse) + { + var sourceBackup = e.Source; owner.RaiseEvent(e); + e.Source = sourceBackup; + } e.Handled = true; } diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 8f1b9dfd22..2369c1f983 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -49,7 +49,7 @@ namespace Avalonia.Controls.Platform internal IMenu? Menu { get; private set; } - protected static TimeSpan MenuShowDelay { get; } = TimeSpan.FromMilliseconds(400); + public static TimeSpan MenuShowDelay { get; set;} = TimeSpan.FromMilliseconds(400); protected internal virtual void GotFocus(object? sender, GotFocusEventArgs e) { diff --git a/src/Avalonia.Controls/Platform/IInputPane.cs b/src/Avalonia.Controls/Platform/IInputPane.cs new file mode 100644 index 0000000000..c36788570c --- /dev/null +++ b/src/Avalonia.Controls/Platform/IInputPane.cs @@ -0,0 +1,89 @@ +using System; +using Avalonia.Animation.Easings; +using Avalonia.Metadata; + +namespace Avalonia.Controls.Platform +{ + /// + /// Listener for the platform's input pane(eg, software keyboard). Provides access to the input pane height and state. + /// + [NotClientImplementable] + public interface IInputPane + { + /// + /// The current input pane state + /// + InputPaneState State { get; } + + /// + /// The current input pane bounds. + /// + Rect OccludedRect { get; } + + /// + /// Occurs when the input pane's state has changed. + /// + event EventHandler? StateChanged; + } + + /// + /// The input pane opened state. + /// + public enum InputPaneState + { + /// + /// The input pane is either closed, or doesn't form part of the platform insets, i.e. it's floating or is an overlay. + /// + Closed, + + /// + /// The input pane is open. + /// + Open + } + + /// + /// Provides state change information about the input pane. + /// + public sealed class InputPaneStateEventArgs : EventArgs + { + /// + /// The new state of the input pane + /// + public InputPaneState NewState { get; } + + /// + /// The initial bounds of the input pane. + /// + public Rect? StartRect { get; } + + /// + /// The final bounds of the input pane. + /// + public Rect EndRect { get; } + + /// + /// The duration of the input pane's state change animation. + /// + public TimeSpan AnimationDuration { get; } + + /// + /// The easing of the input pane's state changed animation. + /// + public IEasing? Easing { get; } + + public InputPaneStateEventArgs(InputPaneState newState, Rect? startRect, Rect endRect, TimeSpan animationDuration, IEasing? easing) + { + NewState = newState; + StartRect = startRect; + EndRect = endRect; + AnimationDuration = animationDuration; + Easing = easing; + } + + public InputPaneStateEventArgs(InputPaneState newState, Rect? startRect, Rect endRect) + : this(newState, startRect, endRect, default, null) + { + } + } +} diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 88577c19db..9a89ccaf28 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using Avalonia.Controls.Documents; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; @@ -34,6 +35,9 @@ namespace Avalonia.Controls.Presenters public static readonly StyledProperty CaretBrushProperty = AvaloniaProperty.Register(nameof(CaretBrush)); + public static readonly StyledProperty CaretBlinkIntervalProperty = + TextBox.CaretBlinkIntervalProperty.AddOwner(); + public static readonly StyledProperty SelectionStartProperty = TextBox.SelectionStartProperty.AddOwner(new(coerce: TextBox.CoerceCaretIndex)); @@ -88,7 +92,7 @@ namespace Avalonia.Controls.Presenters public static readonly StyledProperty BackgroundProperty = Border.BackgroundProperty.AddOwner(); - private readonly DispatcherTimer _caretTimer; + private DispatcherTimer? _caretTimer; private bool _caretBlink; private TextLayout? _textLayout; private Size _constraint; @@ -101,13 +105,10 @@ namespace Avalonia.Controls.Presenters static TextPresenter() { - AffectsRender(CaretBrushProperty, SelectionBrushProperty, TextElement.ForegroundProperty); + AffectsRender(CaretBrushProperty, SelectionBrushProperty, SelectionForegroundBrushProperty, TextElement.ForegroundProperty); } - public TextPresenter() - { - _caretTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; - } + public TextPresenter() { } public event EventHandler? CaretBoundsChanged; @@ -288,6 +289,15 @@ namespace Avalonia.Controls.Presenters set => SetValue(CaretBrushProperty, value); } + /// + /// Gets or sets the caret blink rate + /// + public TimeSpan CaretBlinkInterval + { + get => GetValue(CaretBlinkIntervalProperty); + set => SetValue(CaretBlinkIntervalProperty, value); + } + public int SelectionStart { get => GetValue(SelectionStartProperty); @@ -443,7 +453,7 @@ namespace Avalonia.Controls.Presenters public void ShowCaret() { _caretBlink = true; - _caretTimer.Start(); + _caretTimer?.Start(); InvalidateVisual(); } @@ -454,7 +464,7 @@ namespace Avalonia.Controls.Presenters { TextSelectionHandleCanvas.ShowHandles = false; } - _caretTimer.Stop(); + _caretTimer?.Stop(); InvalidateVisual(); } @@ -465,18 +475,18 @@ namespace Avalonia.Controls.Presenters return; } - if (_caretTimer.IsEnabled) + if (_caretTimer?.IsEnabled ?? false) { _caretBlink = true; - _caretTimer.Stop(); - _caretTimer.Start(); + _caretTimer?.Stop(); + _caretTimer?.Start(); InvalidateVisual(); } else { - _caretTimer.Start(); + _caretTimer?.Start(); InvalidateVisual(); - _caretTimer.Stop(); + _caretTimer?.Stop(); } if (IsMeasureValid) @@ -716,6 +726,33 @@ namespace Avalonia.Controls.Presenters CaretChanged(); } + private void ResetCaretTimer() + { + bool isEnabled = false; + + if (_caretTimer != null) + { + _caretTimer.Tick -= CaretTimerTick; + + if (_caretTimer.IsEnabled) + { + _caretTimer.Stop(); + isEnabled = true; + } + + _caretTimer = null; + } + + if (CaretBlinkInterval.TotalMilliseconds > 0) + { + _caretTimer = new DispatcherTimer { Interval = CaretBlinkInterval }; + _caretTimer.Tick += CaretTimerTick; + + if (isEnabled) + _caretTimer.Start(); + } + } + public CharacterHit GetNextCharacterHit(LogicalDirection direction = LogicalDirection.Forward) { if (Text is null) @@ -855,7 +892,7 @@ namespace Avalonia.Controls.Presenters { base.OnAttachedToVisualTree(e); - _caretTimer.Tick += CaretTimerTick; + ResetCaretTimer(); if (TextSelectionHandleCanvas is { } canvas && _layer != null && !_layer.Children.Contains(canvas)) _layer?.Add(TextSelectionHandleCanvas); @@ -882,9 +919,11 @@ namespace Avalonia.Controls.Presenters c.SetPresenter(null); } - _caretTimer.Stop(); - - _caretTimer.Tick -= CaretTimerTick; + if (_caretTimer != null) + { + _caretTimer.Stop(); + _caretTimer.Tick -= CaretTimerTick; + } } private void OnPreeditChanged(string? preeditText, int? cursorPosition) @@ -939,6 +978,11 @@ namespace Avalonia.Controls.Presenters } } + if (change.Property == CaretBlinkIntervalProperty) + { + ResetCaretTimer(); + } + switch (change.Property.Name) { case nameof(PreeditText): diff --git a/src/Avalonia.Controls/Primitives/RangeBase.cs b/src/Avalonia.Controls/Primitives/RangeBase.cs index 33ab78b66f..b54dd314f9 100644 --- a/src/Avalonia.Controls/Primitives/RangeBase.cs +++ b/src/Avalonia.Controls/Primitives/RangeBase.cs @@ -10,6 +10,8 @@ namespace Avalonia.Controls.Primitives /// public abstract class RangeBase : TemplatedControl { + private bool _isDataContextChanging; + /// /// Defines the property. /// @@ -74,7 +76,7 @@ namespace Avalonia.Controls.Primitives private void OnMinimumChanged() { - if (IsInitialized) + if (IsInitialized && !_isDataContextChanging) { CoerceValue(MaximumProperty); CoerceValue(ValueProperty); @@ -99,8 +101,9 @@ namespace Avalonia.Controls.Primitives private void OnMaximumChanged() { - if (IsInitialized) + if (IsInitialized && !_isDataContextChanging) { + CoerceValue(MinimumProperty); CoerceValue(ValueProperty); } } @@ -170,6 +173,20 @@ namespace Avalonia.Controls.Primitives RaiseEvent(valueChangedEventArgs); } } + + /// + protected override void OnDataContextBeginUpdate() + { + _isDataContextChanging = true; + base.OnDataContextBeginUpdate(); + } + + /// + protected override void OnDataContextEndUpdate() + { + base.OnDataContextEndUpdate(); + _isDataContextChanging = false; + } /// /// Checks if the double value is not infinity nor NaN. diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index b354f06be4..beec0bdcb1 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -513,6 +513,9 @@ namespace Avalonia.Controls.Primitives var containerIsSelected = GetIsSelected(container); UpdateSelection(index, containerIsSelected, toggleModifier: true); } + + if (Selection.AnchorIndex == index) + KeyboardNavigation.SetTabOnceActiveElement(this, container); } /// diff --git a/src/Avalonia.Controls/SelectableTextBlock.cs b/src/Avalonia.Controls/SelectableTextBlock.cs index 4fa8ea989f..143ac29c02 100644 --- a/src/Avalonia.Controls/SelectableTextBlock.cs +++ b/src/Avalonia.Controls/SelectableTextBlock.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using System.Linq; using Avalonia.Controls.Documents; using Avalonia.Controls.Utils; @@ -32,6 +33,9 @@ namespace Avalonia.Controls public static readonly StyledProperty SelectionBrushProperty = TextBox.SelectionBrushProperty.AddOwner(); + public static readonly StyledProperty SelectionForegroundBrushProperty = + TextBox.SelectionForegroundBrushProperty.AddOwner(); + public static readonly DirectProperty CanCopyProperty = TextBox.CanCopyProperty.AddOwner(o => o.CanCopy); @@ -63,6 +67,15 @@ namespace Avalonia.Controls set => SetValue(SelectionBrushProperty, value); } + /// + /// Gets or sets a brush that is used for the foreground of selected text + /// + public IBrush? SelectionForegroundBrush + { + get => GetValue(SelectionForegroundBrushProperty); + set => SetValue(SelectionForegroundBrushProperty, value); + } + /// /// Gets or sets a character index for the beginning of the current selection. /// @@ -167,6 +180,58 @@ namespace Avalonia.Controls UpdateCommandStates(); } + protected override TextLayout CreateTextLayout(string? text) + { + var typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); + + var defaultProperties = new GenericTextRunProperties( + typeface, + FontSize, + TextDecorations, + Foreground); + + var paragraphProperties = new GenericTextParagraphProperties(FlowDirection, TextAlignment, true, false, + defaultProperties, TextWrapping, LineHeight, 0, LetterSpacing) + { + LineSpacing = LineSpacing + }; + + IReadOnlyList>? textStyleOverrides = null; + var selectionStart = SelectionStart; + var selectionEnd = SelectionEnd; + var start = Math.Min(selectionStart, selectionEnd); + var length = Math.Max(selectionStart, selectionEnd) - start; + + if (length > 0 && SelectionForegroundBrush != null) + { + textStyleOverrides = new[] + { + new ValueSpan(start, length, + new GenericTextRunProperties(typeface, FontSize, + foregroundBrush: SelectionForegroundBrush)) + }; + } + + ITextSource textSource; + + if (_textRuns != null) + { + textSource = new InlinesTextSource(_textRuns, textStyleOverrides); + } + else + { + textSource = new FormattedTextSource(text ?? "", defaultProperties, textStyleOverrides); + } + + return new TextLayout( + textSource, + paragraphProperties, + TextTrimming, + _constraint.Width, + _constraint.Height, + MaxLines); + } + protected override void RenderTextLayout(DrawingContext context, Point origin) { var selectionStart = SelectionStart; @@ -220,10 +285,17 @@ namespace Avalonia.Controls { base.OnPropertyChanged(change); - if (change.Property == SelectionStartProperty || change.Property == SelectionEndProperty) + if (change.Property == SelectionStartProperty || + change.Property == SelectionEndProperty) { RaisePropertyChanged(SelectedTextProperty, "", ""); UpdateCommandStates(); + InvalidateTextLayout(); + } + + if(change.Property == SelectionForegroundBrushProperty) + { + InvalidateTextLayout(); } } diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index 5b7ea7b9a5..e71a5a656b 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -73,7 +73,6 @@ namespace Avalonia.Controls { SelectionModeProperty.OverrideDefaultValue(SelectionMode.AlwaysSelected); ItemsPanelProperty.OverrideDefaultValue(DefaultPanel); - TabStripPlacementProperty.Changed.AddClassHandler((x, e) => x.UpdateTabStripPlacement()); AffectsMeasure(TabStripPlacementProperty); SelectedItemProperty.Changed.AddClassHandler((x, e) => x.UpdateSelectedContent()); AutomationProperties.ControlTypeOverrideProperty.OverrideDefaultValue(AutomationControlType.Tab); @@ -154,7 +153,7 @@ namespace Avalonia.Controls protected internal override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) { - return new TabItem { TabStripPlacement = TabStripPlacement }; + return new TabItem(); } protected internal override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) @@ -166,6 +165,11 @@ namespace Avalonia.Controls { base.PrepareContainerForItemOverride(element, item, index); + if (element is TabItem tabItem) + { + tabItem.TabStripPlacement = TabStripPlacement; + } + if (index == SelectedIndex) { UpdateSelectedContent(element); diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 1065bedc4b..cbda1dfbe6 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -7,6 +7,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.TextFormatting; using Avalonia.Metadata; +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -155,8 +156,8 @@ namespace Avalonia.Controls nameof(Inlines), t => t.Inlines, (t, v) => t.Inlines = v); private TextLayout? _textLayout; - private Size _constraint; - private IReadOnlyList? _textRuns; + protected Size _constraint; + protected IReadOnlyList? _textRuns; private InlineCollection? _inlines; /// @@ -846,7 +847,7 @@ namespace Avalonia.Controls InvalidateTextLayout(); } - private readonly record struct SimpleTextSource : ITextSource + protected readonly record struct SimpleTextSource : ITextSource { private readonly string _text; private readonly TextRunProperties _defaultProperties; @@ -875,13 +876,17 @@ namespace Avalonia.Controls } } - private readonly struct InlinesTextSource : ITextSource +#pragma warning disable CA1815 // Equals und Gleichheitsoperator für Werttypen außer Kraft setzen + protected readonly struct InlinesTextSource : ITextSource +#pragma warning restore CA1815 // Equals und Gleichheitsoperator für Werttypen außer Kraft setzen { private readonly IReadOnlyList _textRuns; + private readonly IReadOnlyList>? _textModifier; - public InlinesTextSource(IReadOnlyList textRuns) + public InlinesTextSource(IReadOnlyList textRuns, IReadOnlyList>? textModifier = null) { _textRuns = textRuns; + _textModifier = textModifier; } public IReadOnlyList TextRuns => _textRuns; @@ -904,11 +909,13 @@ namespace Avalonia.Controls continue; } - if (textRun is TextCharacters) + if (textRun is TextCharacters textCharacters) { var skip = Math.Max(0, textSourceIndex - currentPosition); - return new TextCharacters(textRun.Text.Slice(skip), textRun.Properties!); + var textStyleRun = FormattedTextSource.CreateTextStyleRun(textRun.Text.Slice(skip).Span, textSourceIndex, textCharacters.Properties, _textModifier); + + return new TextCharacters(textRun.Text.Slice(skip, textStyleRun.Length), textStyleRun.Value); } return textRun; @@ -916,6 +923,6 @@ namespace Avalonia.Controls return new TextEndOfParagraph(); } - } + } } } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index b6da36d1cb..8bd0286a0a 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -92,6 +92,12 @@ namespace Avalonia.Controls public static readonly StyledProperty CaretBrushProperty = AvaloniaProperty.Register(nameof(CaretBrush)); + /// + /// Defines the property + /// + public static readonly StyledProperty CaretBlinkIntervalProperty = + AvaloniaProperty.Register(nameof(CaretBlinkInterval), defaultValue: TimeSpan.FromMilliseconds(500)); + /// /// Defines the property /// @@ -443,6 +449,13 @@ namespace Avalonia.Controls set => SetValue(CaretBrushProperty, value); } + /// + public TimeSpan CaretBlinkInterval + { + get => GetValue(CaretBlinkIntervalProperty); + set => SetValue(CaretBlinkIntervalProperty, value); + } + /// /// Gets or sets the starting position of the text selected in the TextBox /// diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 6898879b32..5026896d41 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -547,6 +547,7 @@ namespace Avalonia.Controls ?? new NoopStorageProvider(); public IInsetsManager? InsetsManager => PlatformImpl?.TryGetFeature(); + public IInputPane? InputPane => PlatformImpl?.TryGetFeature(); /// /// Gets the platform's clipboard implementation diff --git a/src/Avalonia.Controls/Utils/RealizedStackElements.cs b/src/Avalonia.Controls/Utils/RealizedStackElements.cs index 11bbaa11c4..b8f088f090 100644 --- a/src/Avalonia.Controls/Utils/RealizedStackElements.cs +++ b/src/Avalonia.Controls/Utils/RealizedStackElements.cs @@ -281,19 +281,20 @@ namespace Avalonia.Controls.Utils // elements after the insertion point. var elementCount = _elements.Count; var start = Math.Max(realizedIndex, 0); - var newIndex = realizedIndex + count; for (var i = start; i < elementCount; ++i) { - if (_elements[i] is Control element) - updateElementIndex(element, newIndex - count, newIndex); - ++newIndex; + if (_elements[i] is not Control element) + continue; + var oldIndex = i + first; + updateElementIndex(element, oldIndex, oldIndex + count); } if (realizedIndex < 0) { // The insertion point was before the first element, update the first index. _firstIndex += count; + _startUUnstable = true; } else { @@ -340,7 +341,7 @@ namespace Avalonia.Controls.Utils for (var i = 0; i < _elements.Count; ++i) { if (_elements[i] is Control element) - updateElementIndex(element, newIndex - count, newIndex); + updateElementIndex(element, newIndex + count, newIndex); ++newIndex; } } @@ -383,6 +384,37 @@ namespace Avalonia.Controls.Utils } } + /// + /// Updates the elements in response to items being replaced in the source collection. + /// + /// The index in the source collection of the remove. + /// The number of items removed. + /// A method used to recycle elements. + public void ItemsReplaced(int index, int count, Action recycleElement) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + if (_elements is null || _elements.Count == 0) + return; + + // Get the index within the realized _elements collection. + var startIndex = index - FirstIndex; + var endIndex = Math.Min(startIndex + count, Count); + + if (startIndex >= 0 && endIndex > startIndex) + { + for (var i = startIndex; i < endIndex; ++i) + { + if (_elements[i] is { } element) + { + recycleElement(element); + _elements[i] = null; + _sizes![i] = double.NaN; + } + } + } + } + /// /// Recycles all elements in response to the source collection being reset. /// diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index d1fb4b8269..d234bcbd0d 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -253,6 +253,8 @@ namespace Avalonia.Controls _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); break; case NotifyCollectionChangedAction.Replace: + _realizedElements.ItemsReplaced(e.OldStartingIndex, e.OldItems!.Count, _recycleElementOnItemRemoved); + break; case NotifyCollectionChangedAction.Move: _realizedElements.ItemsRemoved(e.OldStartingIndex, e.OldItems!.Count, _updateElementIndex, _recycleElementOnItemRemoved); _realizedElements.ItemsInserted(e.NewStartingIndex, e.NewItems!.Count, _updateElementIndex); @@ -263,6 +265,16 @@ namespace Avalonia.Controls } } + protected override void OnItemsControlChanged(ItemsControl? oldValue) + { + base.OnItemsControlChanged(oldValue); + + if (oldValue is not null) + oldValue.PropertyChanged -= OnItemsControlPropertyChanged; + if (ItemsControl is not null) + ItemsControl.PropertyChanged += OnItemsControlPropertyChanged; + } + protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap) { var count = Items.Count; @@ -376,7 +388,7 @@ namespace Avalonia.Controls var scrollToElement = GetOrCreateElement(items, index); scrollToElement.Measure(Size.Infinity); - // Get the expected position of the elment and put it in place. + // Get the expected position of the element and put it in place. var anchorU = _realizedElements.GetOrEstimateElementU(index, ref _lastEstimatedElementSizeU); var rect = Orientation == Orientation.Horizontal ? new Rect(anchorU, 0, scrollToElement.DesiredSize.Width, scrollToElement.DesiredSize.Height) : @@ -659,6 +671,7 @@ namespace Avalonia.Controls private void RecycleElement(Control element, int index) { + Debug.Assert(ItemsControl is not null); Debug.Assert(ItemContainerGenerator is not null); _scrollAnchorProvider?.UnregisterAnchorCandidate(element); @@ -673,11 +686,10 @@ namespace Avalonia.Controls { element.IsVisible = false; } - else if (element.IsKeyboardFocusWithin) + else if (KeyboardNavigation.GetTabOnceActiveElement(ItemsControl) == element) { _focusedElement = element; _focusedIndex = index; - _focusedElement.LostFocus += OnUnrealizedFocusedElementLostFocus; } else { @@ -744,15 +756,17 @@ namespace Avalonia.Controls } } - private void OnUnrealizedFocusedElementLostFocus(object? sender, RoutedEventArgs e) + private void OnItemsControlPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { - if (_focusedElement is null || sender != _focusedElement) - return; - - _focusedElement.LostFocus -= OnUnrealizedFocusedElementLostFocus; - RecycleElement(_focusedElement, _focusedIndex); - _focusedElement = null; - _focusedIndex = -1; + if (_focusedElement is not null && + e.Property == KeyboardNavigation.TabOnceActiveElementProperty && + e.GetOldValue() == _focusedElement) + { + // TabOnceActiveElement has moved away from _focusedElement so we can recycle it. + RecycleElement(_focusedElement, _focusedIndex); + _focusedElement = null; + _focusedIndex = -1; + } } /// diff --git a/src/Avalonia.OpenGL/Controls/OpenGlControlResources.cs b/src/Avalonia.OpenGL/Controls/OpenGlControlResources.cs index 3b0b3bd028..be862a3c52 100644 --- a/src/Avalonia.OpenGL/Controls/OpenGlControlResources.cs +++ b/src/Avalonia.OpenGL/Controls/OpenGlControlResources.cs @@ -16,7 +16,7 @@ internal class OpenGlControlBaseResources : IAsyncDisposable public CompositionDrawingSurface Surface { get; } private readonly CompositionOpenGlSwapchain _swapchain; public IGlContext Context { get; private set; } - + public static OpenGlControlBaseResources? TryCreate(CompositionDrawingSurface surface, ICompositionGpuInterop interop, IOpenGlTextureSharingRenderInterfaceContextFeature feature) @@ -54,7 +54,7 @@ internal class OpenGlControlBaseResources : IAsyncDisposable return new OpenGlControlBaseResources(context, surface, interop, null, externalObjects); } - public OpenGlControlBaseResources(IGlContext context, + private OpenGlControlBaseResources(IGlContext context, CompositionDrawingSurface surface, ICompositionGpuInterop interop, IOpenGlTextureSharingRenderInterfaceContextFeature? feature, @@ -161,7 +161,9 @@ internal class OpenGlControlBaseResources : IAsyncDisposable } Surface.Dispose(); + await _swapchain.DisposeAsync(); + Context.Dispose(); Context = null!; } diff --git a/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml b/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml index 228aeaad17..083d90b010 100644 --- a/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml @@ -42,6 +42,7 @@ BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" + CaretIndex="{TemplateBinding CaretIndex, Mode=TwoWay}" FontSize="{TemplateBinding FontSize}" FontFamily="{TemplateBinding FontFamily}" FontWeight="{TemplateBinding FontWeight}" diff --git a/src/Avalonia.Themes.Fluent/Controls/Expander.xaml b/src/Avalonia.Themes.Fluent/Controls/Expander.xaml index adaecc71ab..b725fd67f8 100644 --- a/src/Avalonia.Themes.Fluent/Controls/Expander.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/Expander.xaml @@ -115,7 +115,7 @@ - - + +