diff --git a/osu.Framework.Tests/Visual/Layout/TestSceneBorderLayoutContainer.cs b/osu.Framework.Tests/Visual/Layout/TestSceneBorderLayoutContainer.cs new file mode 100644 index 0000000000..d6eab828aa --- /dev/null +++ b/osu.Framework.Tests/Visual/Layout/TestSceneBorderLayoutContainer.cs @@ -0,0 +1,239 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Framework.Tests.Visual.Layout +{ + [TestFixture] + public partial class TestSceneBorderLayoutContainer : FrameworkTestScene + { + private readonly BorderLayoutContainer borderLayout; + + public TestSceneBorderLayoutContainer() + { + Box top, bottom, left, right; + + Children = new Drawable[] + { + new BorderLayoutContainer + { + RelativeSizeAxes = Axes.Both, + Center = borderLayout = new BorderLayoutContainer + { + RelativeSizeAxes = Axes.Both, + Top = top = new Box + { + RelativeSizeAxes = Axes.X, + Height = 100, + Colour = Color4.Red, + }, + Bottom = bottom = new Box + { + RelativeSizeAxes = Axes.X, + Height = 100, + Colour = Color4.Blue, + }, + Left = left = new Box + { + RelativeSizeAxes = Axes.Y, + Width = 100, + Colour = Color4.Yellow, + }, + Right = right = new Box + { + RelativeSizeAxes = Axes.Y, + Width = 100, + Colour = Color4.Green, + }, + Center = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray + } + }, + Left = new LayoutEdgeParameters("Left") + { + ResizeAction = value => left.Width = value, + VisibilityAction = value => left.Alpha = value ? 1 : 0, + SpacingAction = value => borderLayout.Spacing = borderLayout.Spacing with { Left = value }, + }, + Right = new LayoutEdgeParameters("Right") + { + ResizeAction = value => right.Width = value, + VisibilityAction = value => right.Alpha = value ? 1 : 0, + SpacingAction = value => borderLayout.Spacing = borderLayout.Spacing with { Right = value } + }, + Top = new LayoutEdgeParameters("Top") + { + ResizeAction = value => top.Height = value, + VisibilityAction = value => top.Alpha = value ? 1 : 0, + SpacingAction = value => borderLayout.Spacing = borderLayout.Spacing with { Top = value } + }, + Bottom = new LayoutEdgeParameters("Bottom") + { + ResizeAction = value => bottom.Height = value, + VisibilityAction = value => bottom.Alpha = value ? 1 : 0, + SpacingAction = value => borderLayout.Spacing = borderLayout.Spacing with { Bottom = value } + }, + }, + }; + } + + [Test] + public void TestBorderLayout() + { + AddStep("horizontal layout direction", () => borderLayout.LayoutDirection = Direction.Horizontal); + AddStep("vertical layout direction", () => borderLayout.LayoutDirection = Direction.Vertical); + AddSliderStep("total spacing", 0f, 100f, 0f, value => borderLayout.Spacing = new MarginPadding(value)); + } + + private partial class LayoutEdgeParameters : Container + { + public required Action ResizeAction { private get; init; } + public required Action SpacingAction { private get; init; } + + public Action? VisibilityAction { private get; init; } + + private readonly BindableFloat size = new BindableFloat(100) + { + MinValue = 0, + MaxValue = 300, + }; + + private readonly BindableFloat spacing = new BindableFloat + { + MinValue = 0, + MaxValue = 100, + }; + + private readonly BindableBool visible = new BindableBool(true); + + private readonly Drawable visibilityGroup; + + protected override Container Content { get; } + + public LayoutEdgeParameters(string title) + { + AutoSizeAxes = Axes.Y; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Padding = new MarginPadding(5); + + Width = 150; + + InternalChild = Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + Children = new[] + { + new SpriteText + { + Text = $"{title}:", + Font = new FontUsage(size: 18) + }, + visibilityGroup = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] { new Dimension(), new Dimension(GridSizeMode.AutoSize) }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] + { + new Drawable[] + { + new SpriteText + { + Text = "Visible", + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = new FontUsage(size: 16), + }, + new BasicCheckbox + { + Current = visible, + Scale = new Vector2(0.9f) + } + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new BasicSliderBar + { + RelativeSizeAxes = Axes.X, + Height = 24, + BackgroundColour = Color4.RoyalBlue.Darken(0.75f), + SelectionColour = Color4.RoyalBlue, + FocusColour = Color4.RoyalBlue, + Current = size, + }, + new SpriteText + { + Text = "Size", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = new FontUsage(size: 16) + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new BasicSliderBar + { + RelativeSizeAxes = Axes.X, + Height = 24, + BackgroundColour = Color4.RoyalBlue.Darken(0.75f), + SelectionColour = Color4.RoyalBlue, + FocusColour = Color4.RoyalBlue, + Current = spacing, + }, + new SpriteText + { + Text = "Spacing", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = new FontUsage(size: 16) + }, + } + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + size.BindValueChanged(e => ResizeAction.Invoke(e.NewValue), true); + spacing.BindValueChanged(e => SpacingAction.Invoke(e.NewValue), true); + + if (VisibilityAction != null) + visible.BindValueChanged(e => VisibilityAction?.Invoke(e.NewValue), true); + else + visibilityGroup.Hide(); + } + } + } +} diff --git a/osu.Framework/Graphics/Containers/BorderLayoutContainer.cs b/osu.Framework/Graphics/Containers/BorderLayoutContainer.cs new file mode 100644 index 0000000000..dcc265d441 --- /dev/null +++ b/osu.Framework/Graphics/Containers/BorderLayoutContainer.cs @@ -0,0 +1,262 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Layout; +using osu.Framework.Utils; + +namespace osu.Framework.Graphics.Containers +{ + /// + /// A container that can place a child on each on its borders and will size its children to achieve the following layout: + /// +-------------------------+ + /// | Top | + /// +------+----------+-------+ + /// | | | | + /// | Left | Center | Right | + /// | | | | + /// +------+----------+-------+ + /// | Bottom | + /// +-------------------------+ + /// + public partial class BorderLayoutContainer : CompositeDrawable + { + /// + /// to place at the top edge of this + /// + public Drawable Top + { + set => top.Child = value; + } + + /// + /// to place at the bottom edge of this + /// + public Drawable Bottom + { + set => bottom.Child = value; + } + + /// + /// to place at the left edge of this + /// + public Drawable Left + { + set => left.Child = value; + } + + /// + /// to place at the right edge of this + /// + public Drawable Right + { + set => right.Child = value; + } + + /// + /// to place at the center of this + /// + public Drawable Center + { + set => center.Child = value; + } + + private readonly Container top = new Container + { + Name = "Top", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + + private readonly Container bottom = new Container + { + Name = "Bottom", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }; + + private readonly Container left = new Container + { + Name = "Left", + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + }; + + private readonly Container right = new Container + { + Name = "Right", + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + private readonly Container center = new Container + { + Name = "Center", + RelativeSizeAxes = Axes.Both, + }; + + private Direction layoutDirection = Direction.Vertical; + + /// + /// Whether the layout of the children at horizontal or vertical edges get computed first. + /// + /// Layout for : + /// +-------------------------+ + /// | Top | + /// +------+----------+-------+ + /// | | | | + /// | Left | Center | Right | + /// | | | | + /// +------+----------+-------+ + /// | Bottom | + /// +-------------------------+ + /// + /// Layout for : + /// +-------------------------+ + /// | | Top | | + /// | +----------+ | + /// | | | | + /// | Left | Center | Right | + /// | | | | + /// | +----------+ | + /// | | Bottom | | + /// +------+----------+-------+ + /// + public Direction LayoutDirection + { + get => layoutDirection; + set + { + if (layoutDirection == value) + return; + + layoutDirection = value; + layoutBacking.Invalidate(); + } + } + + private MarginPadding spacing; + + /// + /// Spacing to put between the drawable at each edge and its neighbors. + /// + /// If an edge has no drawable present the spacing for the given edge is ignored. + public MarginPadding Spacing + { + get => spacing; + set + { + if (spacing.Equals(value)) + return; + + if (!Validation.IsFinite(value)) throw new ArgumentException($@"{nameof(Spacing)} must be finite, but is {value}."); + + spacing = value; + layoutBacking.Invalidate(); + } + } + + private readonly LayoutValue layoutBacking = new LayoutValue(Invalidation.DrawSize, InvalidationSource.Self | InvalidationSource.Child); + + public BorderLayoutContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new[] + { + center, + right, + left, + bottom, + top, + }; + + AddLayout(layoutBacking); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!layoutBacking.IsValid) + { + updateLayout(); + layoutBacking.Validate(); + } + } + + private void updateLayout() + { + if (layoutDirection == Direction.Vertical) + { + top.Padding = default; + bottom.Padding = default; + + var padding = new MarginPadding + { + Top = getPadding(top, Direction.Vertical, spacing.Top), + Bottom = getPadding(bottom, Direction.Vertical, spacing.Bottom), + }; + + right.Padding = padding; + left.Padding = padding; + + center.Padding = padding with + { + Left = getPadding(left, Direction.Horizontal, spacing.Left), + Right = getPadding(right, Direction.Horizontal, spacing.Right), + }; + } + else + { + left.Padding = default; + right.Padding = default; + + var padding = new MarginPadding + { + Left = getPadding(left, Direction.Horizontal, spacing.Left), + Right = getPadding(right, Direction.Horizontal, spacing.Right), + }; + + top.Padding = padding; + bottom.Padding = padding; + + center.Padding = padding with + { + Top = getPadding(top, Direction.Vertical, spacing.Top), + Bottom = getPadding(bottom, Direction.Vertical, spacing.Bottom), + }; + } + + static float getPadding(Container container, Direction direction, float spacing) + { + if (container.Children.Count == 0 || !container.Children[0].IsPresent) + return 0; + + var drawable = container.Children[0]; + + switch (direction) + { + case Direction.Horizontal: + if ((drawable.RelativeSizeAxes & Axes.X) != 0) + throw new InvalidOperationException($"Drawables positioned on the left/right edge of a {nameof(BorderLayoutContainer)} cannot be sized relatively along the X axis."); + + return drawable.LayoutSize.X + spacing; + + case Direction.Vertical: + if ((drawable.RelativeSizeAxes & Axes.Y) != 0) + throw new InvalidOperationException($"Drawables positioned on the top/bottom edge of a {nameof(BorderLayoutContainer)} cannot be sized relatively along the Y axis."); + + return drawable.LayoutSize.Y + spacing; + + default: + throw new ArgumentOutOfRangeException(nameof(direction), direction, null); + } + } + } + } +}