iOS SafeArea 자연스럽게 반영해주는 CustomEffect
Android 기기와 달리 iOS 기기는 화면 상단에 위치하는 Status bar 가 Application 화면 크기에 포함되어 있어서 UI 를 만들 때 최상단에 위치한 View 의 Padding
에 20 을 추가해야 화면과 Status bar 가 겹치는 불상사를 피할 수 있습니다. 그래서 대체로 아래와 같이 iOS 에서만 따로 Padding
값을 적용하는 코드를 작성하게 됩니다.
<ContentPage.Padding>
<OnPlatform x:TypeArguments="Thickness">
<OnPlatform.Platforms>
<On Platform="iOS" Value="0, 20, 0, 0" />
</OnPlatform.Platforms>
</OnPlatform>
</ContentPage.Padding>
기기를 회전하지 않으면 이대로 사용해도 문제가 없었는데, 만약 기기를 회전시키면 Status bar 의 위치가 달라지게 되니Padding
값도 회전에 맞춰서 다시 조정해야 합니다. 따라서 기기의 Orientation 의 변화를 감지해서 Padding
의 어느 방향에 값을 적용해야 할지 결정해야 합니다. 그런데 공식 문서가 추천하는 SizeChanged
이벤트에서 width
와 height
비교만으로 Orientation 변화를 감지하는건 'Potrait' 와 'Landscape' 만 감지할 수 있고 'Landscape' 가 오른쪽인지 왼쪽인지를 알 수가 없습니다.
심지어 최신에 출시되는 iOS 기기는 노치 디자인과 화면 위에 있는 소프트 버튼이 또 Application 화면 크기를 덮기 때문에 'Safe Area' 를 적용해야 하는데 값이 기기마다 달라서 이전처럼 일괄적으로 '20' 을 적용할 없게 되었습니다. 그래서 Xamarin 팀에서 준비한 공식 문서 에서는 Page
속성 중 UseSafeArea
를 아래처럼 활성화시키는 방법을 추천하고 있습니다.
<ContentPage
xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
ios:Page.UseSafeArea="true">
<StackLayout>
...
</StackLayout>
</ContentPage>
using Xamarin.Forms.PlatformConfiguration;
using Xamarin.Forms.PlatformConfiguration.iOSSpecific;
// ...
On<iOS>().SetUseSafeArea(true);
그런데... Page
에 SafeArea 를 적용한다는게 전체 화면에 Padding
값을 적용시키는 거라서 아래 그림과 같이 화면 위 아래로 흰 배경이 들어나게 됩니다.
디자인 관점에서 연속되지 않는 영역이 의미없이 등장하도록 'Safe Area' 를 처리하는게 마음이 안들어서, 아래 코드와 같이 CustomEffect
을 만들어서 해결했습니다.
using System;
using System.Reflection;
using Foundation;
using Project.iOS;
using UIKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
[assembly: ResolutionGroupName("Project")]
[assembly: ExportEffect(typeof(SafeAreaEffect), nameof(SafeAreaEffect))]
namespace Project.iOS
{
[Flags]
public enum SafeAreaDomainFlags
{
None = 0b0000,
Left = 0b0001,
Top = 0b0010,
Right = 0b0100,
Bottom = 0b1000,
}
// Xamarin.Forms.Platform.iOS.PageRenderer 코드 중 SafeArea 와 연관된 코드를 참고했습니다.
// https://developer.apple.com/documentation/uikit/uiview/positioning_content_relative_to_the_safe_area
public class SafeAreaEffect : PlatformEffect
{
#pragma warning disable IDE0052 // Remove unread private members
// Observer 가 GC 되지 않도록 방지하는 NSObject
private readonly NSObject _observer;
#pragma warning restore IDE0052 // Remove unread private members
private const float _defaultSafeAreaInsets = 20.0F;
private UIInterfaceOrientation _lastUIinterfaceOrientation;
private VisualElement _currentElement;
private Thickness _originalElementThickness;
private PropertyInfo _targetPropertyInfo;
public SafeAreaEffect()
{
// iOS 개발 하듯이 DidChangeStatusBarOrientation 이벤트를 구독합니다.
_observer = NSNotificationCenter.DefaultCenter.AddObserver(
#pragma warning disable XI0001 // Notifies you with advices on how to use Apple APIs
UIApplication.DidChangeStatusBarOrientationNotification,
#pragma warning restore XI0001 // Notifies you with advices on how to use Apple APIs
OrientationDidChange);
// 원래 아래 코드를 사용했는데 이벤트 발생을 정확하게 감지하지 못하는 버그가 있는거 같아, 위에 코드로 변경했습니다.
// UIDevice.Notifications.ObserveOrientationDidChange(OnOrientationChanged);
// iOS 기기가 이벤트를 DeviceOrientation 과 관련한 이벤트를 발생시키도록 합니다.
UIDevice.CurrentDevice.BeginGeneratingDeviceOrientationNotifications();
_lastUIinterfaceOrientation = UIInterfaceOrientation.Unknown;
}
protected override void OnAttached()
{
if (Element is VisualElement element)
{
// View 에 따라서 SafeArea 를 Padding 에 적용할지, Margin 에 적용할지를 결정합니다.
if (element is Layout || element is Page)
_targetPropertyInfo = element.GetType().GetProperty("Padding");
else
// Label 같은 경우 Padding 을 조절해도 반응이 없기 때문에 Margin 으로 조정합니다.
_targetPropertyInfo = element.GetType().GetProperty("Margin");
// 만약 Safe Area 를 반영할 속성이 없다면, 위에서 추가해주면 됩니다.
if (_targetPropertyInfo != null)
{
_currentElement = element;
_currentElement.SizeChanged += OnCurrentElementSizeChanged;
_originalElementThickness = (Thickness)_targetPropertyInfo.GetValue(_currentElement);
ApplySafeArea();
}
}
}
protected override void OnDetached()
{
// Safe Area 를 적용하기 전 값으로 되돌리고 메모리가 누수되지 않도록 이벤트 구독을 해제합니다.
if (_currentElement != null)
{
_targetPropertyInfo.SetValue(Element, _originalElementThickness);
_currentElement.SizeChanged -= OnCurrentElementSizeChanged;
_currentElement = null;
}
}
private void OrientationDidChange(NSNotification notification)
{
OnOrientationChanged(this, new NSNotificationEventArgs(notification));
}
private void OnOrientationChanged(object sender, NSNotificationEventArgs e)
{
if (_currentElement != null && _lastUIinterfaceOrientation != UIApplication.SharedApplication.StatusBarOrientation)
{
_lastUIinterfaceOrientation = UIApplication.SharedApplication.StatusBarOrientation;
ApplySafeArea();
}
}
// View 의 크기가 달라지면 SafeArea 적용 여부를 판단하는 기준도 달라지므로 SafeArea 를 다시 계산합니다.
private void OnCurrentElementSizeChanged(object sender, EventArgs e)
{
ApplySafeArea();
}
private void ApplySafeArea()
{
var safeAreaInsets = GetSafeAreaInsets();
var safeAreaDomain = GetSafeAreaDomain(_currentElement.Bounds, safeAreaInsets);
var safeAreaThickness = new Thickness
{
Left = safeAreaDomain.HasFlag(SafeAreaDomainFlags.Left) ? safeAreaInsets.Left : 0.0D,
Top = safeAreaDomain.HasFlag(SafeAreaDomainFlags.Top) ? safeAreaInsets.Top : 0.0D,
Right = safeAreaDomain.HasFlag(SafeAreaDomainFlags.Right) ? safeAreaInsets.Right : 0.0D,
Bottom = safeAreaDomain.HasFlag(SafeAreaDomainFlags.Bottom) ? safeAreaInsets.Bottom : 0.0D,
};
var newThickness = new Thickness
{
Left = _originalElementThickness.Left + safeAreaThickness.Left,
Top = _originalElementThickness.Top + safeAreaThickness.Top,
Right = _originalElementThickness.Right + safeAreaThickness.Right,
Bottom = _originalElementThickness.Bottom + safeAreaThickness.Bottom,
};
_targetPropertyInfo.SetValue(_currentElement, newThickness);
}
// iOS 기기에 따라 다른 SafeAreaInsets 을 적용합니다.
private UIEdgeInsets GetSafeAreaInsets()
{
var safeAreaInsets = new UIEdgeInsets();
if (UIDevice.CurrentDevice.CheckSystemVersion(11, 0))
safeAreaInsets = UIApplication.SharedApplication.Windows[0].SafeAreaInsets;
else
{
switch (_lastUIinterfaceOrientation)
{
case UIInterfaceOrientation.Portrait:
safeAreaInsets.Top = _defaultSafeAreaInsets;
break;
case UIInterfaceOrientation.LandscapeLeft:
safeAreaInsets.Right = _defaultSafeAreaInsets;
break;
case UIInterfaceOrientation.PortraitUpsideDown:
safeAreaInsets.Bottom = _defaultSafeAreaInsets;
break;
case UIInterfaceOrientation.LandscapeRight:
safeAreaInsets.Left = _defaultSafeAreaInsets;
break;
}
}
return safeAreaInsets;
}
// View 가 화면 어디에 위치하는지 파악해서 어느 부분에 Safe Area 를 적용할지 계산합니다.
private SafeAreaDomainFlags GetSafeAreaDomain(Rectangle elementBounds, UIEdgeInsets safeAreaInsets)
{
var screenBounds = UIScreen.MainScreen.Bounds;
var safeAreaDomain = SafeAreaDomainFlags.None;
if (elementBounds.Width < 0 || elementBounds.Height < 0)
return SafeAreaDomainFlags.None;
if (elementBounds.Top <= screenBounds.Top + safeAreaInsets.Top)
safeAreaDomain |= SafeAreaDomainFlags.Top;
if (screenBounds.Bottom - safeAreaInsets.Bottom <= elementBounds.Bottom)
safeAreaDomain |= SafeAreaDomainFlags.Bottom;
if (elementBounds.Left <= screenBounds.Left + safeAreaInsets.Left)
safeAreaDomain |= SafeAreaDomainFlags.Left;
if (screenBounds.Right - safeAreaInsets.Right <= elementBounds.Right)
safeAreaDomain |= SafeAreaDomainFlags.Right;
return safeAreaDomain;
}
}
}
View 의 위치와 크기, 그리고 기기의 Orientation 을 파악해서 자동으로 Safe Area 를 적용하도록 했기 때문에 아래와 같이 Effect
만 추가해주면 알아서 Safe Area 가 적용되어서 편리합니다.
<MyForms:NavigationBar
x:Name="_navigationBar"
HorizontalOptions="FillAndExpand"
HeightRequest="50"
BackgroundColor="Yellow"/>
<StackLayout
x:Name="_target"
BackgroundColor="Green"
VerticalOptions="FillAndExpand"
HorizontalOptions="FillAndExpand">
<BoxView BackgroundColor="Blue" />
</StackLayout>
public MainPage()
{
InitializeComponent();
var effect1 = Effect.Resolve("Project.SafeAreaEffect");
_navigationBar.Effects.Add(effect);
var effect2 = Effect.Resolve("Project.SafeAreaEffect");
_target.Effects.Add(effect);
}
'Develop > MAUI 가이드' 카테고리의 다른 글
[Xamarin] Renderer 를 만들었더니 BackgroundColor 가 적용되지 않을 때 해결하는 방법 (0) | 2021.03.25 |
---|---|
[Xamarin] View 안에 View 를 주입할 수 있는 View 를 작성해보자 (0) | 2020.12.24 |
[Xamarin] BindableProperty 를 Override 해보자 (0) | 2020.12.24 |
[Xamarin] Item 이 Layout 에 따라 자동으로 배치되도록 해보자 (0) | 2020.12.24 |
[Xamarin] 화면 크기에 따라 변화하는 ResponsiveLayout 을 만들어 보자 (0) | 2020.12.23 |
꾸준히 노력하는 개발자 "김예건" 입니다.