본문 바로가기

Develop/MAUI 가이드

[Xamarin] 화면 크기에 따라 변화하는 ResponsiveLayout 을 만들어 보자

반응형

Custom Layout
Custom Layout

화면 크기에 따라 변화하는 ResponsiveLayout 을 만들어 보자

먼저 이 글의 목적은 두 가지입니다. 먼저 공식 문서의 엉성한 번역으로 고생하시는 개발자 분들에게 친절한 설명을 제공하는게 목적입니다. 그리고 추가적으로 이 글 이후에 작성할 ItemsView 에 대한 글을 이해하기 위한 기초 지식을 제공하고자 합니다.

Layout 을 직접 구현해야 할까?

Xamarin 에서 기본적을 제공하는 Layout 은 아래와 같이 5 가지가 제공됩니다.

Layouts
Layouts

각 Layout 마다 특징도 다르고 앱을 만들 때 5 가지 이외 레이아웃이 필요한 상황도 드물기 때문에, Layout 을 직접 구현하게 되는건 드뭅니다. 하지만 Layout 구현에 대한 기초적인 지식을 가지고 있으면 View 의 크기와 배치가 어떻게 결정되는지를 알 수 있어, 의도한 바와 달리 보이는 UI 를 적절하게 수정할 수 있습니다.

개인적으로 Layout 을 직접 구현하는게 도움이 된 상황은 성능을 개선해야 할 때였습니다. 여러 Layout 을 중첩해서 하나의 재사용 가능한 View 를 만든 적이 있는데 해당 View 가 등장하면, 중첩된 Layout 으로 인해 버벅이는 문제가 발생했었습니다. 이때 Layout 을 중첩시키는 대신 Custom Layout 으로 구현했더니 성능이 개선되었습니다.

Layout 을 구현하는데 알아야 하는 사항

Layout Cycle

Layout Cycle 은 맨 처음 화면 UI 를 구성할 때 진행하는 과정으로, Layout Cycle 진행할 때 모든 자식 View 의 Measure 메서드 또는 Layout 메서드를 반드시 호출해야 합니다. Measure 메서드로 자식 View 는 부모 View 에게 원하는 크기의 공간 배정을 요구하고, Layout 메서드로 Layout 을 구성할 책임을 자식 Layout 에게 전가합니다. Layout Cycle 이 끝나고 나면 화면 구성이 완료되고 이후 Layout 변경은 다음에 소개할 Invalidation Cycle 을 통해 업데이트하게 됩니다.

Layout Cycle
Layout Cycle

Invalidation Cycle

Invalidation CycleLayout Cycle 로 초기 화면 구성이 끝난 후 Layout 을 업데이트할 때마다 진행되는 과정입니다. 하위 View 에서 상위 View 로 MeasureInvalidated 이벤트를 전파하며 각 View 가 어떻게 변화할지 결정하는 과정입니다.

Invalidation Cycle
Invalidation Cycle

반드시 알아야만 하는 사항

Xamarin 에서 모든 Layout 은 LayoutLayout<T> 를 상속받아 구현합니다. Xamarin 에서 기본으로 제공하는 StackLayout 도 아래와 같이 Layout<View> 를 상속받아 구현되어 있습니다.

public class StackLayout : Layout<View>, IElementConfiguration<StackLayout>

그리고 Layout<T>Layout 을 상속받기 때문에 Layout 클래스를 이해하는게 제일 중요합니다.

public abstract class Layout<T> : Layout, IViewContainer<T> where T : View

Layout 클래스는 abstract 즉 추상 클래스이며, 핵심적으로 알아야 하는 메서드는 아래와 같습니다.

// 화면에 나타나는 모든 Page 와 View 가 상속받는 클래스입니다.
public partial class VisualElement
{
    // 상위 View 에게 View 가 필요로 하는 크기를 반환하는 메서드입니다.
    protected virtual SizeRequest OnMeasure(double widthConstraint, double heightConstraint);
}

// Page 를 구성하게 될 모든 View 가 상속받는 클래스입니다.
public class View : VisualElement
{
    // ...
}

// 다른 View 를 포함할 수 있는 모든 Layout 이 상속받는 클래스입니다. 
public abstract class Layout : View
{
    // Layout 에 포함되는 모든 View 들의 크기와 위치를 결정하는 메서드입니다.
    protected abstract void LayoutChildren(double x, double y, double width, double height);

    // Layout 을 다시 계산해야 할 때 호출되는 메서드입니다.
    // 새로운 View 가 추가되거나 제거될 때 Layout 을 다시 계산해야 하기 때문에 호출됩니다.
    protected virtual void InvalidateLayout();

    // Layout 내에 View 중 하나가 MeasureInvalidated 이벤트를 발생시켰을 때 호출되는 메서드입니다.
    protected virtual void OnChildMeasureInvalidated()

}

Layout 은 아래와 같은 틀 안에서 구현하면 됩니다.

public class MyLayout : Layout<View>
{
    // 필수로 구현해야 메서드
    protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
    {
        // 상위 View 에 요구할 Size 를 계산하고 SizeRequest 담아 반환하면 됩니다.
    }

    // 필수로 구현해야 하는 메서드
    protected override void LayoutChildren(double x, double y, double width, double height)
    {
        // Layout 의 형태를 결정하는 메서드이기 때문에 제일 중요합니다.
    }

    // Layout 에서 View 가 추가되거나 제거될 때 대응하려면 구현해야 하는 메서드
    protected override void InvalidateLayout()
    {
        base.InvalidateLayout();
    }

    // Layout 에 있는 View 가 Invalidated 되었을 때 대응하려면 구현해야 하는 메서드
    // (대표적으로 View 가 Invalidate 되는 때는 View 의 크기가 변경되었을 때입니다.)
    protected override void OnChildMeasureInvalidated()
    {
        base.OnChildMeasureInvalidated();
    }

}
protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)

OnMeasure 메서드는 Layout 클래스가 아닌 VisualElement 클래스에 정의된 메서드로 부모 View 가 호출하는 메서드로 자식 View 가 원하는 크기를 얻을 때 사용합니다. 그래서 OnMeasure 메서드로 전달되는 double widthConstraint, double heightConstraint 는 부모 View 가 지정해서 자식 View 에게 전달하며, Double.PositiveInfinity 가 전달되면 자식 View 가 알아서 크기를 결정하라는 의미입니다.

protected override void LayoutChildren(double x, double y, double width, double height)

LayoutChildren 메서드로 전달되는 double x, double y, double width, double heightView 가 Layout 내에서 위치할 수 있는 좌표와 크기입니다. 좌표는 절대좌표가 아닌 상대좌표로 Layout 좌상단이 x = 0, y = 0 입니다. 예를 들어 크기가 width = 100, height = 100 인 Layout 에 Padding = 10 을 적용하면 View 가 위치할 수 있는 크기가 줄어서 LayoutChildren 메서드로 x = 10, y = 10, width = 90, height = 90 가 전달됩니다.

ResponsiveLayout 소스코드

ResponsiveLayout 은 화면 크기에 따라 또는 Layout 에게 배정되는 크기에 따라 내부 View 의 배치를 변경하는 유연한 Layout 입니다. ResponsiveLayout 은 공식 문서에서 소개하는 WrapLayout 의 코드를 깔끔하게 정리해서 만든 클래스입니다.

// https://docs.microsoft.com/en-us/xamarin/xamarin-forms/user-interface/layouts/custom
public class ResponsiveLayout : Layout<View>, IGridSpacingElement
{
    #region Row 간격
    public static readonly BindableProperty RowSpacingProperty = GridSpacingElement.RowSpacingProperty;
    public void OnRowSpacingChanged(double newRowSpacing) => InvalidateLayout();
    public double RowSpacing
    {
        get => (double)GetValue(RowSpacingProperty);
        set => SetValue(RowSpacingProperty, value);
    }
    #endregion

    #region Column 간격
    public static readonly BindableProperty ColumnSpacingProperty = GridSpacingElement.ColumnSpacingProperty;
    public void OnColumnSpacingChanged(double newColumnSpacing) => InvalidateLayout();
    public double ColumnSpacing
    {
        get => (double)GetValue(ColumnSpacingProperty);
        set => SetValue(ColumnSpacingProperty, value);
    }
    #endregion

    // 계산된 Layout 크기를 저장하는 속성입니다.
    private readonly IDictionary<Size, LayoutCache> _cache;

    public ResponsiveLayout()
    {
        _cache = new Dictionary<Size, LayoutCache>();
    }

    protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint)
    {
        var cache = CalculateLayout(widthConstraint, heightConstraint);
        // 보여줄 View 가 없다면 width = 0, height = 0  를 반환합니다.
        if (cache.VisibleChildCount < 1)
            return new SizeRequest();
        else
            return new SizeRequest(
                // Layout 이 필요한 크기를 계산하여 반환합니다.
                new Size
                {
                    Width = cache.CellSize.Width * cache.Columns + ColumnSpacing * (cache.Columns - 1),
                    Height = cache.CellSize.Height * cache.Rows + RowSpacing * (cache.Rows - 1),
                }
            );
    }

    protected override void LayoutChildren(double x, double y, double width, double height)
    {
        var cache = CalculateLayout(width, height);

        if (cache.VisibleChildCount < 1)
            return;

        var childX = x;
        var childY = y;

        var row = 0;
        var column = 0;

        // Layout 에 포함된 View 의 위치와 크기를 결정합니다.
        foreach(var child in Children)
        {
            if (!child.IsVisible)
                continue;

            // View 를 Layout 좌표 기준으로 Rectangle 안으로 가두는 메서드입니다.
            LayoutChildIntoBoundingRegion(child, new Rectangle
            {
                X = childX,
                Y = childY,
                Width = cache.CellSize.Width,
                Height = cache.CellSize.Height,
            });

            // 다음 View 가 Layout 좌표 내에서 위치할 X 와 Y 좌표를 계산합니다.
            if (++column == cache.Columns)
            {
                column = 0;
                row += 1;

                childX = x;
                childY += RowSpacing + cache.CellSize.Height;
            }
            else
                childX += ColumnSpacing + cache.CellSize.Width;
        }
    }

    protected override void InvalidateLayout()
    {
        base.InvalidateLayout();

        // Layout 크기를 다시 계산해야 하므로 사전에 계산해놓은 Layout 크기는 폐기합니다.
        _cache.Clear();
    }

    protected override void OnChildMeasureInvalidated()
    {
        base.OnChildMeasureInvalidated();

        // Layout 크기를 다시 계산해야 하므로 사전에 계산해놓은 Layout 크기는 폐기합니다.
        _cache.Clear();
    }

    private LayoutCache CalculateLayout(double width, double height)
    {
        var newSize = new Size(width, height);

        // 이미 계산된 Layout 이 있는지 확인합니다.
        if (_cache.ContainsKey(newSize))
            return _cache[newSize];

        var visibleChildCount = 0;
        var maxChildSize = new Size();
        var rows = 0;
        var columns = 0;

        var newLayoutCache = new LayoutCache();

        foreach(var child in Children)
        {
            // Visible 하지 않은 View 는 Layout 크기 계산에 포함시킬 필요가 없습니다.
            if (!child.IsVisible)
                continue;
            else
                visibleChildCount++;

            // View 가 Layout 에게 원하는 크기를 구합니다.
            var childSizeRequest = child.Measure(double.PositiveInfinity, double.PositiveInfinity);

            // 최대 View 의 크기를 구해서 Layout 내에서 View 를 배치하기 위한 정보로 활용합니다.
            maxChildSize.Width = Math.Max(maxChildSize.Width, childSizeRequest.Request.Width);
            maxChildSize.Height = Math.Max(maxChildSize.Height, childSizeRequest.Request.Height);
        }

        // Visible 한 View 개수에 따라 Row 와 Column 을 결정합니다.
        if(visibleChildCount > 0)
        {
            // Layout 의 Width 가 무한하면, Row 는 1 로 고정되고 Column 이 View 의 개수에 맞춰 증가합니다.
            if (double.IsPositiveInfinity(width))
            {
                columns = visibleChildCount;
                rows = 1;
            }
            else
            {
                // Column 은 최소 1 이상이어야 합니다.
                columns = Math.Max(1, (int)((width + ColumnSpacing) / (maxChildSize.Width + ColumnSpacing)));
                rows = (visibleChildCount + columns - 1) / columns;
            }

            // View 에게 배정할 Cell 크기를 결정합니다.
            var cellSize = new Size();
            if (double.IsPositiveInfinity(width))
                cellSize.Width = maxChildSize.Width;
            else
                cellSize.Width = (width - ColumnSpacing * (columns - 1)) / columns;

            if (double.IsPositiveInfinity(height))
                cellSize.Height = maxChildSize.Height;
            else
                cellSize.Height = (height - RowSpacing * (rows - 1)) / rows;

            newLayoutCache = new LayoutCache(visibleChildCount, cellSize, rows, columns);
        }

        _cache.Add(newSize, newLayoutCache);
        return newLayoutCache;
    }
}

IGridSpacingElement

BindableProperty 아름답게 관리하는 Element 패턴다른 글을 참고해주시길 바랍니다.

[EditorBrowsable(EditorBrowsableState.Never)]
interface IGridSpacingElement
{
    double RowSpacing { get; set; }
    void OnRowSpacingChanged(double newRowSpacing);

    double ColumnSpacing { get; set; }
    void OnColumnSpacingChanged(double newColumnSpacing);
}
static class GridSpacingElement
{
    public static readonly BindableProperty RowSpacingProperty
        = BindableProperty.Create(
            propertyName: nameof(IGridSpacingElement.RowSpacing),
            returnType: typeof(double),
            declaringType: typeof(IGridSpacingElement),
            defaultValue: 0.0D,
            defaultBindingMode: BindingMode.OneWay,
            propertyChanged: OnRowSpacingChanged);
    private static void OnRowSpacingChanged(BindableObject bindable, object oldValue, object newValue)
        => (bindable as IGridSpacingElement).OnRowSpacingChanged((double)newValue);

    public static readonly BindableProperty ColumnSpacingProperty
        = BindableProperty.Create(
            propertyName: nameof(IGridSpacingElement.ColumnSpacing),
            returnType: typeof(double),
            declaringType: typeof(IGridSpacingElement),
            defaultValue: 0.0D,
            defaultBindingMode: BindingMode.OneWay,
            propertyChanged: OnColumnSpacingChanged);
    private static void OnColumnSpacingChanged(BindableObject bindable, object oldValue, object newValue)
        => (bindable as IGridSpacingElement).OnColumnSpacingChanged((double)newValue);
}

LayoutCache

/// Layout 를 매번 다시 계산하지 않도록 caching 하기 위한 struct
struct LayoutCache
{
    public int VisibleChildCount { get; private set; }
    public Size CellSize { get; private set; }
    public int Rows { get; private set; }
    public int Columns { get; private set; }
    public LayoutCache(int visibleChildCount, Size cellSize, int rows, int columns)
    {
        VisibleChildCount = visibleChildCount;
        CellSize = cellSize;
        Rows = rows;
        Columns = columns;
    }
}
반응형