A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Threading;
6using osu.Framework.Allocation;
7using osu.Framework.Extensions.PolygonExtensions;
8using osu.Framework.Graphics.Primitives;
9using osu.Framework.Layout;
10using osu.Framework.Threading;
11
12namespace osu.Framework.Graphics.Containers
13{
14 /// <summary>
15 /// A container which asynchronously loads specified content.
16 /// Has the ability to delay the loading until it has been visible on-screen for a specified duration.
17 /// In order to benefit from delayed load, we must be inside a <see cref="ScrollContainer{T}"/>.
18 /// </summary>
19 public class DelayedLoadWrapper : CompositeDrawable
20 {
21 [Resolved]
22 protected Game Game { get; private set; }
23
24 private readonly Func<Drawable> createFunc;
25
26 /// <summary>
27 /// Creates a <see cref="Container"/> that will asynchronously load the given <see cref="Drawable"/> with a delay.
28 /// </summary>
29 /// <remarks>If <see cref="timeBeforeLoad"/> is set to 0, the loading process will begin on the next Update call.</remarks>
30 /// <param name="content">The <see cref="Drawable"/> to be loaded.</param>
31 /// <param name="timeBeforeLoad">The delay in milliseconds before loading can begin.</param>
32 public DelayedLoadWrapper(Drawable content, double timeBeforeLoad = 500)
33 : this(timeBeforeLoad)
34 {
35 Content = content ?? throw new ArgumentNullException(nameof(content), $@"{nameof(DelayedLoadWrapper)} required non-null {nameof(content)}.");
36 }
37
38 /// <summary>
39 /// Creates a <see cref="Container"/> that will asynchronously load the given <see cref="Drawable"/> with a delay.
40 /// This constructor is preferred due to avoiding construction of the loadable content until a load is actually triggered.
41 /// </summary>
42 /// <remarks>If <see cref="timeBeforeLoad"/> is set to 0, the loading process will begin on the next Update call.</remarks>
43 /// <param name="createFunc">A function which created future content.</param>
44 /// <param name="timeBeforeLoad">The delay in milliseconds before loading can begin.</param>
45 public DelayedLoadWrapper(Func<Drawable> createFunc, double timeBeforeLoad = 500)
46 : this(timeBeforeLoad)
47 {
48 this.createFunc = createFunc;
49 }
50
51 private DelayedLoadWrapper(double timeBeforeLoad)
52 {
53 this.timeBeforeLoad = timeBeforeLoad;
54
55 AddLayout(optimisingContainerCache);
56 AddLayout(isIntersectingCache);
57 }
58
59 private Drawable content;
60
61 public Drawable Content
62 {
63 get => content;
64 protected set
65 {
66 if (content == value)
67 return;
68
69 content = value;
70
71 if (content == null)
72 return;
73
74 AutoSizeAxes = Axes.None;
75 RelativeSizeAxes = Axes.None;
76
77 RelativeSizeAxes = content.RelativeSizeAxes;
78 AutoSizeAxes = (content as CompositeDrawable)?.AutoSizeAxes ?? AutoSizeAxes;
79 }
80 }
81
82 /// <summary>
83 /// The amount of time on-screen in milliseconds before we begin a load of children.
84 /// </summary>
85 private readonly double timeBeforeLoad;
86
87 private double timeVisible;
88
89 protected virtual bool ShouldLoadContent => timeVisible > timeBeforeLoad;
90
91 private CancellationTokenSource cancellationTokenSource;
92 private ScheduledDelegate scheduledAddition;
93
94 protected override void Update()
95 {
96 base.Update();
97
98 // This code can be expensive, so only run if we haven't yet loaded.
99 if (DelayedLoadCompleted || DelayedLoadTriggered) return;
100
101 if (!IsIntersecting)
102 timeVisible = 0;
103 else
104 timeVisible += Time.Elapsed;
105
106 if (ShouldLoadContent)
107 BeginDelayedLoad();
108 }
109
110 protected void BeginDelayedLoad()
111 {
112 if (DelayedLoadTriggered || DelayedLoadCompleted)
113 throw new InvalidOperationException("Load has already started!");
114
115 Content ??= createFunc();
116
117 DelayedLoadTriggered = true;
118 DelayedLoadStarted?.Invoke(Content);
119
120 cancellationTokenSource = new CancellationTokenSource();
121
122 // The callback is run on the game's scheduler since DelayedLoadUnloadWrapper needs to unload when no updates are being received.
123 LoadComponentAsync(Content, EndDelayedLoad, scheduler: Game.Scheduler, cancellation: cancellationTokenSource.Token);
124 }
125
126 protected virtual void EndDelayedLoad(Drawable content)
127 {
128 timeVisible = 0;
129
130 // This code is running on the game's scheduler, while this wrapper may have been async disposed, so the addition is scheduled locally to prevent adding to disposed wrappers.
131 scheduledAddition = Schedule(() =>
132 {
133 AddInternal(content);
134
135 DelayedLoadCompleted = true;
136 DelayedLoadComplete?.Invoke(content);
137 });
138 }
139
140 internal override void UnbindAllBindables()
141 {
142 base.UnbindAllBindables();
143 CancelTasks();
144 }
145
146 protected override void Dispose(bool isDisposing)
147 {
148 base.Dispose(isDisposing);
149 CancelTasks();
150 }
151
152 protected virtual void CancelTasks()
153 {
154 isIntersectingCache.Invalidate();
155
156 cancellationTokenSource?.Cancel();
157 cancellationTokenSource = null;
158
159 scheduledAddition?.Cancel();
160 scheduledAddition = null;
161 }
162
163 /// <summary>
164 /// Fired when delayed async load has started.
165 /// </summary>
166 public event Action<Drawable> DelayedLoadStarted;
167
168 /// <summary>
169 /// Fired when delayed async load completes. Should be used to perform transitions.
170 /// </summary>
171 public event Action<Drawable> DelayedLoadComplete;
172
173 /// <summary>
174 /// True if the load task for our content has been started.
175 /// Will remain true even after load is completed.
176 /// </summary>
177 protected bool DelayedLoadTriggered;
178
179 /// <summary>
180 /// True if the content has been added to the drawable hierarchy.
181 /// </summary>
182 public bool DelayedLoadCompleted { get; protected set; }
183
184 private readonly LayoutValue optimisingContainerCache = new LayoutValue(Invalidation.Parent);
185 private readonly LayoutValue isIntersectingCache = new LayoutValue(Invalidation.All);
186 private ScheduledDelegate isIntersectingResetDelegate;
187
188 protected bool IsIntersecting { get; private set; }
189
190 internal IOnScreenOptimisingContainer OptimisingContainer { get; private set; }
191
192 internal IOnScreenOptimisingContainer FindParentOptimisingContainer() => FindClosestParent<IOnScreenOptimisingContainer>();
193
194 protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
195 {
196 var result = base.OnInvalidate(invalidation, source);
197
198 // For every invalidation, we schedule a reset of IsIntersecting to the game.
199 // This is done since UpdateSubTreeMasking() may not be invoked in the current frame, as a result of presence/masking changes anywhere in our super-tree.
200 // It is important that this is scheduled such that it occurs on the NEXT frame, in order to give this wrapper a chance to load its contents.
201 // For example, if a parent invalidated this wrapper every frame, IsIntersecting would be false by the time Update() is run and may only become true at the very end of the frame.
202 // The scheduled delegate will be cancelled if this wrapper has its UpdateSubTreeMasking() invoked, as more accurate intersections can be computed there instead.
203 if (isIntersectingResetDelegate == null)
204 {
205 isIntersectingResetDelegate = Game?.Scheduler.AddDelayed(() => IsIntersecting = false, 0);
206 result = true;
207 }
208
209 return result;
210 }
211
212 public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds)
213 {
214 bool result = base.UpdateSubTreeMasking(source, maskingBounds);
215
216 // We can accurately compute intersections - the scheduled reset is no longer required.
217 isIntersectingResetDelegate?.Cancel();
218 isIntersectingResetDelegate = null;
219
220 if (!isIntersectingCache.IsValid)
221 {
222 if (!optimisingContainerCache.IsValid)
223 {
224 OptimisingContainer = FindParentOptimisingContainer();
225 optimisingContainerCache.Validate();
226 }
227
228 // The first condition is an intersection against the hierarchy, including any parents that may be masking this wrapper.
229 // It is the same calculation as Drawable.IsMaskedAway, however IsMaskedAway is optimised out for some CompositeDrawables (which this wrapper is).
230 // The second condition is an exact intersection against the optimising container, which further optimises rotated AABBs where the wrapper content is not visible.
231 IsIntersecting = maskingBounds.IntersectsWith(ScreenSpaceDrawQuad.AABBFloat)
232 && OptimisingContainer?.ScreenSpaceDrawQuad.Intersects(ScreenSpaceDrawQuad) != false;
233
234 isIntersectingCache.Validate();
235 }
236
237 return result;
238 }
239
240 /// <summary>
241 /// A container which acts as a masking parent for on-screen delayed load optimisations.
242 /// </summary>
243 internal interface IOnScreenOptimisingContainer : IDrawable
244 {
245 }
246 }
247}