화면 크기에 따라 변화하는 ResponsiveLayout
을 만들어 보자
먼저 이 글의 목적은 두 가지입니다. 먼저 공식 문서의 엉성한 번역으로 고생하시는 개발자 분들에게 친절한 설명을 제공하는게 목적입니다. 그리고 추가적으로 이 글 이후에 작성할 ItemsView
에 대한 글을 이해하기 위한 기초 지식을 제공하고자 합니다.
Layout 을 직접 구현해야 할까?
Xamarin 에서 기본적을 제공하는 Layout 은 아래와 같이 5 가지가 제공됩니다.
각 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 을 통해 업데이트하게 됩니다.
Invalidation Cycle
Invalidation Cycle 은 Layout Cycle 로 초기 화면 구성이 끝난 후 Layout 을 업데이트할 때마다 진행되는 과정입니다. 하위 View 에서 상위 View 로 MeasureInvalidated
이벤트를 전파하며 각 View 가 어떻게 변화할지 결정하는 과정입니다.
반드시 알아야만 하는 사항
Xamarin 에서 모든 Layout 은 Layout
과 Layout<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 height
는 View
가 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;
}
}
'Develop > MAUI 가이드' 카테고리의 다른 글
[Xamarin] BindableProperty 를 Override 해보자 (0) | 2020.12.24 |
---|---|
[Xamarin] Item 이 Layout 에 따라 자동으로 배치되도록 해보자 (0) | 2020.12.24 |
[Xamarin] BindableProperty 구현하고 관리하는 방법 (0) | 2020.12.17 |
[Xamarin] Effects 로 Platform-Specific 하게 구현하기 (0) | 2020.11.17 |
[Xamarin] Custom Renderer 로 Platform-Specific 하게 구현하기 (0) | 2020.09.29 |
꾸준히 노력하는 개발자 "김예건" 입니다.