18 private const string SubRootName =
"sub-root";
19 private const int MinOffsetApplyAttempts = 1;
20 private const int MaxOffsetApplyAttempts = 10;
21 private const float MinApplyOffsetDelaySeconds = 0f;
22 private const float MaxApplyOffsetDelaySeconds = 1f;
24#if UNITY_6000_4_OR_NEWER
25 private static readonly Dictionary<EntityId, Dictionary<BannerAnchor, EntityId>> sideLocksByContainer =
new();
27 private static readonly Dictionary<int, Dictionary<BannerAnchor, int>> sideLocksByContainer =
new();
32 #region Serialized fields
35 [Tooltip(
"Canvas that hosts UI content. If null, it is searched on this GameObject.")]
36 [SerializeField]
private Canvas canvas;
39 [Tooltip(
"Banner ad component implementing IBannerAd.")]
43 [Tooltip(
"Optional banner ad controller that keeps a Banner reference available, including when the Banner persists between scenes.")]
47 [Tooltip(
"Optional container to offset. If null, a default sub-root is created and used.")]
48 [SerializeField]
private RectTransform explicitContainer;
51 [Tooltip(
"Delay in seconds between repeated offset applications. Zero waits until the end of the frame.")]
52 [Range(MinApplyOffsetDelaySeconds, MaxApplyOffsetDelaySeconds)]
53 [SerializeField]
private float applyOffsetDelaySeconds = MinApplyOffsetDelaySeconds;
56 [Tooltip(
"Number of offset applications performed after a banner becomes visible.")]
57 [Range(MinOffsetApplyAttempts, MaxOffsetApplyAttempts)]
58 [SerializeField]
private int offsetApplyAttempts = 1;
61 [Tooltip(
"Extra canvas-unit margin applied when the banner is anchored to the top.")]
62 [SerializeField]
private float marginTop;
65 [Tooltip(
"Extra canvas-unit margin applied when the banner is anchored to the bottom.")]
66 [SerializeField]
private float marginBottom;
69 [Tooltip(
"Extra canvas-unit margin applied when the banner is anchored to the left.")]
70 [SerializeField]
private float marginLeft;
73 [Tooltip(
"Extra canvas-unit margin applied when the banner is anchored to the right.")]
74 [SerializeField]
private float marginRight;
77 [Tooltip(
"When true, only the banner side is inset. When false, full shift is allowed if the opposite side is free.")]
78 [SerializeField]
private bool shiftOnlyBannerSide =
true;
81 [Tooltip(
"When true, safe-area inset on the banner side is subtracted from the applied banner offset.")]
82 [SerializeField]
private bool accountForSafeArea =
true;
89 private volatile bool isComponentActive;
92 private RectTransform targetContainer;
94 private (
float top,
float bottom,
float left,
float right) appliedInsets;
95 private bool hasAppliedInsets;
96 private Vector2 appliedShiftOffset;
97 private bool hasAppliedShiftOffset;
98 private bool hasInitialState;
99 private bool isSubscribed;
100 private Coroutine offsetRefreshCoroutine;
101 private int offsetRefreshVersion;
105 #region Unity lifecycle
113 protected virtual void OnValidate()
115 if (Application.isPlaying)
return;
118 TryGetComponent(out canvas);
120 bannerAdComponent = bannerAdComponent ? bannerAdComponent : FindBannerAdCandidate();
122 if (bannerAdComponent ==
null || bannerAdComponent is
IBannerAd)
return;
125 $
"{nameof(BannerCanvasOffsetHelper)}: Assigned banner component must implement {nameof(IBannerAd)}.",
127 bannerAdComponent =
null;
138 if (bannerAdController !=
null && bannerAdController.Ad is
IBannerAd)
139 return bannerAdController.Ad;
141#if UNITY_6000_3_OR_NEWER
142 var behaviours = FindObjectsByType<AdUnitBehaviourBase>(FindObjectsInactive.Exclude);
143#elif UNITY_2023_1_OR_NEWER
144 var behaviours = FindObjectsByType<AdUnitBehaviourBase>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
146 var behaviours = FindObjectsOfType<AdUnitBehaviourBase>();
156 protected virtual void Reset()
158 if (Application.isPlaying)
return;
161 TryGetComponent(out canvas);
166#if UNITY_6000_3_OR_NEWER
167 canvas = FindAnyObjectByType<Canvas>();
168#elif UNITY_2023_1_OR_NEWER
169 canvas = FindFirstObjectByType<Canvas>();
171 canvas = FindObjectOfType<Canvas>();
179 protected virtual void Awake() => isComponentActive = isActiveAndEnabled;
188 isComponentActive =
true;
189 CancelOffsetRefresh();
190 ClearAppliedOffsetState();
192 if (!ResolveRuntimeReferences())
212 isComponentActive =
false;
213 CancelOffsetRefresh();
216 ClearRuntimeReferences();
230 if (!isComponentActive)
233 if (!EnsureRuntimeReferences())
238 case { IsVisible:
true }:
240 ScheduleOffsetRefresh();
242 case { IsVisible:
false }:
243 CancelOffsetRefresh();
251 #region Event handlers
277 private bool EnsureRuntimeReferences()
279 if (!hasInitialState || bannerAd ==
null || targetContainer ==
null)
281 if (!ResolveRuntimeReferences())
295 private bool ResolveRuntimeReferences()
298 TryGetComponent(out canvas);
302 Debug.LogWarning($
"{nameof(BannerCanvasOffsetHelper)}: Canvas not found.",
this);
306 bannerAd = ResolveBannerAd();
307 if (bannerAd ==
null)
310 $
"{nameof(BannerCanvasOffsetHelper)}: Banner component implementing {nameof(IBannerAd)} not found.",
315 targetContainer = ResolveTargetContainer();
316 if (targetContainer ==
null)
320 hasInitialState =
true;
330 private IBannerAd ResolveBannerAd()
332 if (bannerAdComponent !=
null)
334 if (bannerAdComponent is IBannerAd directBanner)
338 $
"{nameof(BannerCanvasOffsetHelper)}: Assigned banner component must implement {nameof(IBannerAd)}.",
343 if (bannerAdController !=
null && bannerAdController.Ad is IBannerAd controllerBanner)
345 bannerAdComponent = bannerAdController.Ad;
346 return controllerBanner;
358 private RectTransform ResolveTargetContainer()
360 var root = canvas.transform as RectTransform;
363 Debug.LogWarning($
"{nameof(BannerCanvasOffsetHelper)}: Canvas root RectTransform not found.",
this);
367 if (explicitContainer ==
null)
368 return EnsureSubRoot(root);
370 if (explicitContainer.transform.IsChildOf(root))
371 return explicitContainer;
374 $
"{nameof(BannerCanvasOffsetHelper)}: Explicit container is outside the Canvas hierarchy. " +
375 $
"Falling back to '{SubRootName}'.",
378 return EnsureSubRoot(root);
388 private static RectTransform EnsureSubRoot(RectTransform root)
390 var subRoot = root.Find(SubRootName) as RectTransform;
393 var subRootObject =
new GameObject(SubRootName, typeof(RectTransform));
394 subRoot = subRootObject.GetComponent<RectTransform>();
395 subRoot.SetParent(root,
false);
396 ConfigureSubRoot(subRoot);
399 var children =
new List<Transform>(root.childCount);
400 for (var index = 0; index < root.childCount; index++)
402 var child = root.GetChild(index);
403 if (child == subRoot)
409 foreach (var child
in children)
411 child.SetParent(subRoot,
false);
423 private static void ConfigureSubRoot(RectTransform subRoot)
425 subRoot.anchorMin = Vector2.zero;
426 subRoot.anchorMax = Vector2.one;
427 subRoot.offsetMin = Vector2.zero;
428 subRoot.offsetMax = Vector2.zero;
429 subRoot.anchoredPosition = Vector2.zero;
430 subRoot.localScale = Vector3.one;
441 if (!shiftOnlyBannerSide || container ==
null)
444 if (IsStretched(container))
448 $
"{nameof(BannerCanvasOffsetHelper)}: Target container is not stretched. " +
449 "Single-side inset mode modifies RectTransform offsets and is intended for stretched containers.",
460 private static bool IsStretched(RectTransform rectTransform) =>
461 rectTransform.anchorMin == Vector2.zero &&
462 rectTransform.anchorMax == Vector2.one;
469 private void ClearRuntimeReferences()
472 targetContainer =
null;
473 ClearAppliedOffsetState();
474 hasInitialState =
false;
479 #region Event subscription
486 private void Subscribe()
488 if (isSubscribed || bannerAd ==
null)
491 bannerAd.OnShown += OnBannerShown;
492 bannerAd.OnHide += OnBannerHidden;
501 private void Unsubscribe()
506 if (bannerAd !=
null)
508 bannerAd.OnShown -= OnBannerShown;
509 bannerAd.OnHide -= OnBannerHidden;
512 isSubscribed =
false;
524 private void ApplyOffset()
526 if (!hasInitialState || targetContainer ==
null || bannerAd ==
null)
529 if (shiftOnlyBannerSide)
531 ApplySingleSideInset();
535 ApplyFullShiftOffset();
541 private void ScheduleOffsetRefresh()
543 CancelOffsetRefresh();
545 var attempts = Mathf.Clamp(offsetApplyAttempts, MinOffsetApplyAttempts, MaxOffsetApplyAttempts);
549 var requestVersion = offsetRefreshVersion;
550 offsetRefreshCoroutine = StartCoroutine(RefreshOffsetAfterFrames(requestVersion, attempts - 1));
558 private IEnumerator RefreshOffsetAfterFrames(
int requestVersion,
int remainingAttempts)
560 while (remainingAttempts > 0)
562 var delaySeconds = Mathf.Clamp(
563 applyOffsetDelaySeconds,
564 MinApplyOffsetDelaySeconds,
565 MaxApplyOffsetDelaySeconds);
566 if (delaySeconds <= MinApplyOffsetDelaySeconds)
567 yield
return new WaitForEndOfFrame();
569 yield
return new WaitForSeconds(delaySeconds);
571 if (requestVersion != offsetRefreshVersion || !isComponentActive || bannerAd is not { IsVisible:
true })
578 offsetRefreshCoroutine =
null;
584 private void CancelOffsetRefresh()
586 offsetRefreshVersion++;
588 if (offsetRefreshCoroutine ==
null)
591 StopCoroutine(offsetRefreshCoroutine);
592 offsetRefreshCoroutine =
null;
600 private void ResetOffset()
602 if (!hasInitialState || targetContainer ==
null)
605 RemoveAppliedInsets();
606 RemoveAppliedShiftOffset();
607 ReleaseSideLock(appliedAnchor);
608 ClearAppliedOffsetState();
616 private void ApplySingleSideInset()
618 RemoveAppliedInsets();
619 RemoveAppliedShiftOffset();
620 ReleaseSideLock(appliedAnchor);
622 var insets = GetInsetValues();
623 if (IsZeroInsets(insets))
625 ClearAppliedOffsetState();
629 var side = GetInsetSide(insets);
630 if (!TryAcquireSideLock(side))
632 ClearAppliedOffsetState();
636 var offsetMin = targetContainer.offsetMin;
637 var offsetMax = targetContainer.offsetMax;
638 AddInsetsToOffsets(ref offsetMin, ref offsetMax, insets);
639 targetContainer.offsetMin = offsetMin;
640 targetContainer.offsetMax = offsetMax;
642 appliedAnchor = side;
643 appliedInsets = insets;
644 hasAppliedInsets =
true;
645 appliedShiftOffset = Vector2.zero;
646 hasAppliedShiftOffset =
false;
654 private void ApplyFullShiftOffset()
656 RemoveAppliedInsets();
657 RemoveAppliedShiftOffset();
658 ReleaseSideLock(appliedAnchor);
660 var offset = GetShiftOffsetVector();
661 if (IsZeroVector(offset))
663 ClearAppliedOffsetState();
667 var side = GetShiftLockSide();
668 if (!TryAcquireSideLock(side))
670 ClearAppliedOffsetState();
674 targetContainer.anchoredPosition += offset;
676 appliedAnchor = side;
677 appliedInsets = (0f, 0f, 0f, 0f);
678 hasAppliedInsets =
false;
679 appliedShiftOffset = offset;
680 hasAppliedShiftOffset =
true;
688 private void RemoveAppliedInsets()
690 if (!hasAppliedInsets || targetContainer ==
null)
693 var offsetMin = targetContainer.offsetMin;
694 var offsetMax = targetContainer.offsetMax;
695 RemoveInsetsFromOffsets(ref offsetMin, ref offsetMax, appliedInsets);
696 targetContainer.offsetMin = offsetMin;
697 targetContainer.offsetMax = offsetMax;
699 appliedInsets = (0f, 0f, 0f, 0f);
700 hasAppliedInsets =
false;
708 private void RemoveAppliedShiftOffset()
710 if (!hasAppliedShiftOffset || targetContainer ==
null)
713 targetContainer.anchoredPosition -= appliedShiftOffset;
715 appliedShiftOffset = Vector2.zero;
716 hasAppliedShiftOffset =
false;
725 private (
float top,
float bottom,
float left,
float right) GetInsetValues()
727 if (bannerAd ==
null)
728 return (0f, 0f, 0f, 0f);
730 var anchor = bannerAd.GetBannerAnchor();
731 var effectiveExtent = GetEffectiveBannerExtent(anchor);
735 BannerAnchor.Top => (effectiveExtent, 0f, 0f, 0f),
737 BannerAnchor.Left => (0f, 0f, effectiveExtent, 0f),
739 _ => (0f, 0f, 0f, 0f)
749 private Vector2 GetShiftOffsetVector()
751 if (bannerAd ==
null)
754 var anchor = bannerAd.GetBannerAnchor();
755 var effectiveExtent = GetEffectiveBannerExtent(anchor);
759 BannerAnchor.Top =>
new Vector2(0f, -effectiveExtent),
760 BannerAnchor.Bottom =>
new Vector2(0f, effectiveExtent),
761 BannerAnchor.Left =>
new Vector2(effectiveExtent, 0f),
762 BannerAnchor.Right =>
new Vector2(-effectiveExtent, 0f),
774 private float GetEffectiveBannerExtent(
BannerAnchor anchor)
776 var resolvedBannerExtentPixels = GetResolvedBannerExtent(anchor);
777 var safeAreaInsetPixels = accountForSafeArea ? GetSafeAreaInsetForAnchor(anchor) : 0f;
778 var effectiveExtentPixels = Mathf.Max(0f, resolvedBannerExtentPixels - safeAreaInsetPixels);
780 return ConvertScreenPixelsToCanvasUnits(effectiveExtentPixels, anchor) + GetMarginForAnchor(anchor);
790 private float GetResolvedBannerExtent(
BannerAnchor anchor)
792 if (bannerAd ==
null)
795 var resolvedHeight = Mathf.Max(0f, bannerAd.GetBannerHeightPixels());
796 var resolvedWidth = Mathf.Max(0f, bannerAd.GetBannerWidthPixels());
797 if (resolvedWidth <= 0f)
798 resolvedWidth = Screen.width;
802 BannerAnchor.Top or BannerAnchor.Bottom => resolvedHeight,
803 BannerAnchor.Left or BannerAnchor.Right => resolvedWidth,
816 private float ConvertScreenPixelsToCanvasUnits(
float screenPixels,
BannerAnchor anchor)
818 if (Mathf.Approximately(screenPixels, 0f))
821 var root = canvas !=
null ? canvas.transform as RectTransform :
null;
825 var isHorizontalAxis = anchor is BannerAnchor.Left or
BannerAnchor.Right;
826 var screenExtent = isHorizontalAxis ? Screen.width : Screen.height;
827 var canvasExtent = isHorizontalAxis ? root.rect.width : root.rect.height;
829 if (screenExtent > 0f && canvasExtent > 0f)
830 return screenPixels * canvasExtent / screenExtent;
832 var scaleFactor = canvas !=
null ? Mathf.Max(1f, canvas.scaleFactor) : 1f;
833 return screenPixels / scaleFactor;
843 private float GetMarginForAnchor(
BannerAnchor anchor) =>
846 BannerAnchor.Top => marginTop,
847 BannerAnchor.Bottom => marginBottom,
848 BannerAnchor.Left => marginLeft,
849 BannerAnchor.Right => marginRight,
860 private static float GetSafeAreaInsetForAnchor(
BannerAnchor anchor)
862 var (top, bottom, left, right) = GetSafeAreaInsets();
865 BannerAnchor.Top => top,
866 BannerAnchor.Bottom => bottom,
867 BannerAnchor.Left => left,
868 BannerAnchor.Right => right,
879 private static (
float top,
float bottom,
float left,
float right) GetSafeAreaInsets()
881 var safeArea = Screen.safeArea;
883 top: Mathf.Max(0f, Screen.height - (safeArea.y + safeArea.height)),
884 bottom: Mathf.Max(0f, safeArea.y),
885 left: Mathf.Max(0f, safeArea.x),
886 right: Mathf.Max(0f, Screen.width - (safeArea.x + safeArea.width)));
897 ? BannerAnchor.Custom
898 : bannerAd.GetBannerAnchor()
switch
904 _ => BannerAnchor.Custom
915 private static void AddInsetsToOffsets(
916 ref Vector2 offsetMin,
917 ref Vector2 offsetMax,
918 (
float top,
float bottom,
float left,
float right) insets)
920 offsetMin =
new Vector2(offsetMin.x + insets.left, offsetMin.y + insets.bottom);
921 offsetMax =
new Vector2(offsetMax.x - insets.right, offsetMax.y - insets.top);
932 private static void RemoveInsetsFromOffsets(
933 ref Vector2 offsetMin,
934 ref Vector2 offsetMax,
935 (
float top,
float bottom,
float left,
float right) insets)
937 offsetMin =
new Vector2(offsetMin.x - insets.left, offsetMin.y - insets.bottom);
938 offsetMax =
new Vector2(offsetMax.x + insets.right, offsetMax.y + insets.top);
948 private static bool IsZeroInsets((
float top,
float bottom,
float left,
float right) insets) =>
949 Mathf.Approximately(insets.top, 0f) &&
950 Mathf.Approximately(insets.bottom, 0f) &&
951 Mathf.Approximately(insets.left, 0f) &&
952 Mathf.Approximately(insets.right, 0f);
961 private static bool IsZeroVector(Vector2 shiftOffset) =>
962 Mathf.Approximately(shiftOffset.x, 0f) &&
963 Mathf.Approximately(shiftOffset.y, 0f);
970 private void ClearAppliedOffsetState()
973 appliedInsets = (0f, 0f, 0f, 0f);
974 hasAppliedInsets =
false;
975 appliedShiftOffset = Vector2.zero;
976 hasAppliedShiftOffset =
false;
986 private static BannerAnchor GetInsetSide((
float top,
float bottom,
float left,
float right) insets) =>
987 !Mathf.Approximately(insets.top, 0f) ? BannerAnchor.Top :
988 !Mathf.Approximately(insets.bottom, 0f) ? BannerAnchor.Bottom :
989 !Mathf.Approximately(insets.left, 0f) ? BannerAnchor.Left :
990 !Mathf.Approximately(insets.right, 0f) ? BannerAnchor.Right :
BannerAnchor.Custom;
1001 side = NormalizeLockAnchor(side);
1002 if (side ==
BannerAnchor.Custom || targetContainer ==
null)
1005#if UNITY_6000_4_OR_NEWER
1006 var containerId = targetContainer.GetEntityId();
1008 var containerId = targetContainer.GetInstanceID();
1011 if (!sideLocksByContainer.TryGetValue(containerId, out var sideLocks))
1013#if UNITY_6000_4_OR_NEWER
1014 sideLocks =
new Dictionary<BannerAnchor, EntityId>();
1016 sideLocks =
new Dictionary<BannerAnchor, int>();
1018 sideLocksByContainer[containerId] = sideLocks;
1021#if UNITY_6000_4_OR_NEWER
1022 var ownerId = GetEntityId();
1024 var ownerId = GetInstanceID();
1027 if (sideLocks.TryGetValue(side, out var lockedBy) && lockedBy != ownerId)
1030 sideLocks[side] = ownerId;
1042 side = NormalizeLockAnchor(side);
1043 if (side ==
BannerAnchor.Custom || targetContainer ==
null)
1046#if UNITY_6000_4_OR_NEWER
1047 var containerId = targetContainer.GetEntityId();
1049 var containerId = targetContainer.GetInstanceID();
1052 if (!sideLocksByContainer.TryGetValue(containerId, out var sideLocks))
1055#if UNITY_6000_4_OR_NEWER
1056 var ownerId = GetEntityId();
1058 var ownerId = GetInstanceID();
1061 if (!sideLocks.TryGetValue(side, out var lockedBy) || lockedBy != ownerId)
1064 sideLocks.Remove(side);
1065 if (sideLocks.Count == 0)
1066 sideLocksByContainer.Remove(containerId);
1077 anchor is BannerAnchor.Center or BannerAnchor.Custom
1078 ? BannerAnchor.Custom