본문 바로가기

Develop/MAUI 가이드

[Xamarin] iOS SafeArea 자연스럽게 반영해주는 CustomEffect

반응형

iOS SafeArea 자연스럽게 반영해주는 CustomEffect
iOS SafeArea 자연스럽게 반영해주는 CustomEffect

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 이벤트에서 widthheight 비교만으로 Orientation 변화를 감지하는건 'Potrait' 와 'Landscape' 만 감지할 수 있고 'Landscape' 가 오른쪽인지 왼쪽인지를 알 수가 없습니다.

iOS Safe Area
iOS Safe Area

심지어 최신에 출시되는 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);

UseSafeArea Disabled and Enabled
UseSafeArea 비활성 / 활성

그런데... Page 에 SafeArea 를 적용한다는게 전체 화면에 Padding 값을 적용시키는 거라서 아래 그림과 같이 화면 위 아래로 흰 배경이 들어나게 됩니다.

ugly_safe_area
못생긴 Safe Area

디자인 관점에서 연속되지 않는 영역이 의미없이 등장하도록 '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);
}

적용 결과 1
적용 결과 1

적용 결과 2
적용 결과 2

반응형