diff --git a/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs b/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs index e7a6e9a543..641c1ad523 100644 --- a/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs +++ b/osu.Game.Tests/Online/TestSceneBeatmapDownloading.cs @@ -91,7 +91,7 @@ namespace osu.Game.Tests.Online { AddStep("download beatmap", () => beatmaps.Download(test_db_model)); - AddStep("cancel download from notification", () => recentNotification.Close()); + AddStep("cancel download from notification", () => recentNotification.Close(true)); AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_db_model) == null); AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index 704dda3068..3d13e98865 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -12,9 +12,9 @@ using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Updater; using osuTK; using osuTK.Input; -using osu.Game.Updater; namespace osu.Game.Tests.Visual.UserInterface { @@ -48,6 +48,40 @@ namespace osu.Game.Tests.Visual.UserInterface notificationOverlay.UnreadCount.ValueChanged += count => { displayedCount.Text = $"displayed count: {count.NewValue}"; }; }); + [Test] + public void TestDismissWithoutActivationFling() + { + bool activated = false; + SimpleNotification notification = null!; + + AddStep("post", () => + { + activated = false; + notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"Welcome to osu!. Enjoy your stay!", + Activated = () => activated = true, + }); + }); + + AddStep("start drag", () => + { + InputManager.MoveMouseTo(notification.ChildrenOfType().Single()); + InputManager.PressButton(MouseButton.Left); + InputManager.MoveMouseTo(notification.ChildrenOfType().Single().ScreenSpaceDrawQuad.Centre + new Vector2(-500, 0)); + }); + + AddStep("fling away", () => + { + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("wait for closed", () => notification.WasClosed); + AddAssert("was not activated", () => !activated); + AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count zero", () => notificationOverlay.UnreadCount.Value == 0); + } + [Test] public void TestDismissWithoutActivationCloseButton() { @@ -75,6 +109,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for closed", () => notification.WasClosed); AddAssert("was not activated", () => !activated); AddStep("reset mouse position", () => InputManager.MoveMouseTo(Vector2.Zero)); + AddAssert("unread count zero", () => notificationOverlay.UnreadCount.Value == 0); } [Test] @@ -220,6 +255,26 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("cancel notification", () => notification.State = ProgressNotificationState.Cancelled); } + [Test] + public void TestReadState() + { + SimpleNotification notification = null!; + AddStep(@"post", () => notificationOverlay.Post(notification = new BackgroundNotification { Text = @"Welcome to osu!. Enjoy your stay!" })); + AddUntilStep("check is toast", () => !notification.IsInToastTray); + AddAssert("light is not visible", () => notification.ChildrenOfType().Single().Alpha == 0); + + AddUntilStep("wait for forward to overlay", () => !notification.IsInToastTray); + + setState(Visibility.Visible); + AddAssert("state is not read", () => !notification.Read); + AddUntilStep("light is visible", () => notification.ChildrenOfType().Single().Alpha == 1); + + setState(Visibility.Hidden); + setState(Visibility.Visible); + AddAssert("state is read", () => notification.Read); + AddUntilStep("light is not visible", () => notification.ChildrenOfType().Single().Alpha == 0); + } + [Test] public void TestUpdateNotificationFlow() { diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index a3678602d1..877c90a534 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -111,7 +111,7 @@ namespace osu.Game.Database { if (error is WebException webException && webException.Message == @"TooManyRequests") { - notification.Close(); + notification.Close(false); PostNotification?.Invoke(new TooManyDownloadsNotification()); } else diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index b170ea5dfa..15573f99af 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -158,7 +158,10 @@ namespace osu.Game.Overlays playDebouncedSample(notification.PopInSampleName); if (State.Value == Visibility.Hidden) + { + notification.IsInToastTray = true; toastTray.Post(notification); + } else addPermanently(notification); @@ -167,6 +170,8 @@ namespace osu.Game.Overlays private void addPermanently(Notification notification) { + notification.IsInToastTray = false; + var ourType = notification.GetType(); int depth = notification.DisplayOnTop ? -runningDepth : runningDepth; diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index 40324963fc..1da05dab75 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -78,7 +78,6 @@ namespace osu.Game.Overlays { LayoutDuration = 150, LayoutEasing = Easing.OutQuart, - Spacing = new Vector2(3), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, }, @@ -118,7 +117,7 @@ namespace osu.Game.Overlays return; // Notification hovered; delay dismissal. - if (notification.IsHovered) + if (notification.IsHovered || notification.IsDragged) { scheduleDismissal(); return; diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 67739c2089..6c1b43c3b5 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -2,16 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; @@ -56,13 +59,40 @@ namespace osu.Game.Overlays.Notifications protected Container MainContent; + private readonly DragContainer dragContainer; + public virtual bool Read { get; set; } + public new bool IsDragged => dragContainer.IsDragged; + protected virtual IconUsage CloseButtonIcon => FontAwesome.Solid.Check; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + public override bool PropagatePositionalInputSubTree => base.PropagatePositionalInputSubTree && !WasClosed; + + private bool isInToastTray; + + /// + /// Whether this notification is in the . + /// + public bool IsInToastTray + { + get => isInToastTray; + set + { + isInToastTray = value; + + if (!isInToastTray) + { + dragContainer.ResetPosition(); + if (!Read) + Light.FadeIn(100); + } + } + } + private readonly Box initialFlash; private Box background = null!; @@ -76,11 +106,19 @@ namespace osu.Game.Overlays.Notifications { Light = new NotificationLight { + Alpha = 0, Margin = new MarginPadding { Right = 5 }, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreRight, }, - MainContent = new Container + dragContainer = new DragContainer(this) + { + // Use margin instead of FillFlow spacing to fix extra padding appearing when notification shrinks + // in height. + Padding = new MarginPadding { Vertical = 3f }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }.WithChild(MainContent = new Container { CornerRadius = 6, Masking = true, @@ -130,7 +168,7 @@ namespace osu.Game.Overlays.Notifications }, new CloseButton(CloseButtonIcon) { - Action = Close, + Action = () => Close(true), Anchor = Anchor.TopRight, Origin = Anchor.TopRight, } @@ -144,7 +182,7 @@ namespace osu.Game.Overlays.Notifications Blending = BlendingParameters.Additive, }, } - } + }) }; } @@ -176,7 +214,7 @@ namespace osu.Game.Overlays.Notifications // right click doesn't trigger OnClick so we need to handle here until that changes. if (e.Button != MouseButton.Left) { - Close(); + Close(true); return true; } @@ -189,7 +227,7 @@ namespace osu.Game.Overlays.Notifications if (e.Button == MouseButton.Left) Activated?.Invoke(); - Close(); + Close(false); return true; } @@ -207,17 +245,131 @@ namespace osu.Game.Overlays.Notifications public bool WasClosed; - public virtual void Close() + public virtual void Close(bool runFlingAnimation) { if (WasClosed) return; WasClosed = true; + if (runFlingAnimation && dragContainer.FlingLeft()) + this.FadeOut(600, Easing.In); + else + this.FadeOut(100); + Closed?.Invoke(); - this.FadeOut(100); Expire(); } + private class DragContainer : Container + { + private Vector2 velocity; + private Vector2 lastPosition; + + private readonly Notification notification; + + public DragContainer(Notification notification) + { + this.notification = notification; + } + + public override RectangleF BoundingBox + { + get + { + var childBounding = Children.First().BoundingBox; + + if (X < 0) childBounding *= new Vector2(1, Math.Max(0, 1 + (X / 300))); + if (Y > 0) childBounding *= new Vector2(1, Math.Max(0, 1 - (Y / 200))); + + return childBounding; + } + } + + protected override bool OnDragStart(DragStartEvent e) => notification.IsInToastTray; + + protected override void OnDrag(DragEvent e) + { + if (!notification.IsInToastTray) + return; + + Vector2 change = e.MousePosition - e.MouseDownPosition; + + // Diminish the drag distance as we go further to simulate "rubber band" feeling. + change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.8f) / change.Length; + + // Only apply Y change if dragging to the left. + if (change.X >= 0) + change.Y = 0; + else + change.Y *= (float)Interpolation.ApplyEasing(Easing.InOutQuart, Math.Min(1, -change.X / 200)); + + this.MoveTo(change); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (Rotation < -10 || velocity.X < -0.3f) + notification.Close(true); + else + ResetPosition(); + + base.OnDragEnd(e); + } + + private bool flinging; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + Rotation = Math.Min(0, X * 0.1f); + + if (flinging) + { + velocity.Y += (float)Clock.ElapsedFrameTime * 0.005f; + Position += (float)Clock.ElapsedFrameTime * velocity; + } + else if (Clock.ElapsedFrameTime > 0) + { + Vector2 change = (Position - lastPosition) / (float)Clock.ElapsedFrameTime; + + if (velocity.X == 0) + velocity = change; + else + { + velocity = new Vector2( + (float)Interpolation.DampContinuously(velocity.X, change.X, 40, Clock.ElapsedFrameTime), + (float)Interpolation.DampContinuously(velocity.Y, change.Y, 40, Clock.ElapsedFrameTime) + ); + } + + lastPosition = Position; + } + } + + public bool FlingLeft() + { + if (!notification.IsInToastTray) + return false; + + if (flinging) + return true; + + if (velocity.X > -0.3f) + velocity.X = -0.3f - 0.5f * RNG.NextSingle(); + + flinging = true; + ClearTransforms(); + return true; + } + + public void ResetPosition() + { + this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); + this.RotateTo(0, 800, Easing.OutElastic); + } + } + internal class CloseButton : OsuClickableContainer { private SpriteIcon icon = null!; diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index d2e18a0cee..16105f913f 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -106,14 +106,13 @@ namespace osu.Game.Overlays.Notifications RelativeSizeAxes = Axes.X, LayoutDuration = 150, LayoutEasing = Easing.OutQuart, - Spacing = new Vector2(3), } }); } private void clearAll() { - notifications.Children.ForEach(c => c.Close()); + notifications.Children.ForEach(c => c.Close(true)); } protected override void Update() diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index bdf6f704e5..c4d402e5b9 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -142,7 +142,7 @@ namespace osu.Game.Overlays.Notifications case ProgressNotificationState.Completed: loadingSpinner.Hide(); attemptPostCompletion(); - base.Close(); + base.Close(false); break; } } @@ -235,12 +235,12 @@ namespace osu.Game.Overlays.Notifications }); } - public override void Close() + public override void Close(bool runFlingAnimation) { switch (State) { case ProgressNotificationState.Cancelled: - base.Close(); + base.Close(runFlingAnimation); break; case ProgressNotificationState.Active: diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 49009e9124..100464029b 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -148,7 +148,7 @@ namespace osu.Game.Updater StartDownload(); } - public override void Close() + public override void Close(bool runFlingAnimation) { // cancelling updates is not currently supported by the underlying updater. // only allow dismissing for now. @@ -156,7 +156,7 @@ namespace osu.Game.Updater switch (State) { case ProgressNotificationState.Cancelled: - base.Close(); + base.Close(runFlingAnimation); break; } } @@ -177,7 +177,7 @@ namespace osu.Game.Updater public void FailDownload() { State = ProgressNotificationState.Cancelled; - Close(); + Close(false); } } }