Baseflow / LottieXamarin

Render After Effects animations natively on Android, iOS, MacOS and TvOS for Xamarin
https://baseflow.com
Apache License 2.0
1.22k stars 261 forks source link

Xamarin.Forms: having events OnAnimationUpdate and OnRepeatAnimation on iOS #298

Closed pcdus closed 3 years ago

pcdus commented 3 years ago

🚀 Feature Requests

During some tests, I've seen that the events OnAnimationUpdate and OnRepeatAnimation are only fired on Android.

Contextualize the feature

When we want to use the Lottie animation as Loading animation in Xamarin.Forms, these events could help to manage the animation display.

By default, we can use:

<lottie:AnimationView Animation="resource://lottie_loading.json?assembly=MyApp"
        AnimationSource="EmbeddedResource"
        BackgroundColor="Transparent"
        AutoPlay="True"
        RepeatMode="Infinite"
        IsVisible="{Binding IsBusy}">

But in some cases, the loading is too quick so the animation is only visible during a short time, which is not a great user experience.

To optimize this, we could display the animation least once fully, or to always play the animation fully before to hide it.

For this, we can use OnFinishedAnimation event and create another property like ShowAnimation that will be set during data loading.

For example:

private async Task LoadDataAsync()
{
    IsBusy = true;
    ShowAnimation = true;
    await Task.Delay(4500);
    IsBusy = false;
}

The Lottie animation will now be bind to this property, without the infinite RepeatMode:

<lottie:AnimationView Animation="resource://lottie_loading.json?assembly=MyApp"
        AnimationSource="EmbeddedResource"
        BackgroundColor="Transparent"
        AutoPlay="True"
        IsVisible="{Binding ShowAnimation}">

And through EventToCommandBehavior we will be able to access to the OnFinishedAnimation event in the ViewModel:

public ICommand OnFinishedAnimationCommand
{
    get
    {
        return new Xamarin.Forms.Command<object>(async (object sender) =>
        {
            if (sender != null)
            {
                await OnFinishedAnimation(sender);
            }
        });
    }
}

private Task OnFinishedAnimation(object sender)
{
    var view = sender as AnimationView;
    if (IsBusy)
    {
        view.PlayAnimation();
    }
    else
    {
        ShowAnimation = false;
    }
    return Task.CompletedTask;
}

This works well, but there is then another issue: if we reload the data through "Retry" button after an error, or through a "Refresh" icon toolbar, the Lottie animation is not relaunched automatically.

Describe the feature

To fix this, we could use the events OnAnimationUpdate and OnRepeatAnimation: but they are not fired on iOS.

Platforms affected (mark all that apply)

martijn00 commented 3 years ago

Can you make a PR to fix this?

pcdus commented 3 years ago

I can take a look, but I already have an error when I try to build the sources project.

pcdus commented 3 years ago

For the moment, I found another solution by using OnFinishedAnimation , even if this solution is a bit heavy and can be improved.

Firstly, as recommended there, I've created 2 Triggers:

public class PlayLottieAnimationTriggerAction : TriggerAction<AnimationView>
{
    protected override void Invoke(AnimationView sender)
    {
        Debug.WriteLine($"PlayLottieAnimationTriggerAction()");
        sender.PlayAnimation();
    }
}

public class StopLottieAnimationTriggerAction : TriggerAction<AnimationView>
{
    protected override void Invoke(AnimationView sender)
    {
        Debug.WriteLine($"StopLottieAnimationTriggerAction()");
        sender.StopAnimation();
    }
}

I also used EventToCommandBehaviors, like described there.

After this I can use the Lottie animation like this:

<forms:AnimationView 
    x:Name="animationView" 
    BackgroundColor="Transparent"
    AutoPlay="True"
    IsVisible="{Binding ShowAnimation}"
    Animation="resource://lottie_4squares_apricot_blond.json?assembly=Example.Forms"
    AnimationSource="EmbeddedResource"
    VerticalOptions="FillAndExpand" 
    HorizontalOptions="FillAndExpand">
    <forms:AnimationView.Triggers>
        <MultiTrigger TargetType="forms:AnimationView">
            <MultiTrigger.Conditions>
                <BindingCondition Binding="{Binding ShowAnimation}" Value="True" />
            </MultiTrigger.Conditions>
            <MultiTrigger.EnterActions>
                <triggers:LottieTriggerAction />
            </MultiTrigger.EnterActions>
            <MultiTrigger.ExitActions>
                <actions:StopLottieAnimationTriggerAction />
            </MultiTrigger.ExitActions>
        </MultiTrigger>
    </forms:AnimationView.Triggers>
    <forms:AnimationView.Behaviors>
        <behaviors:EventToCommandBehavior
            EventName="OnFinishedAnimation"
            Command="{Binding OnFinishedAnimationCommand}"
            CommandParameter="{x:Reference animationView}"/>
    </forms:AnimationView.Behaviors>
</forms:AnimationView>

And in my ViewModel, I've declared a property ShowAnimation that is related to IsBusy and the Command OnFinishedAnimationCommand like this:

private bool _showAnimation;
public bool ShowAnimation
{
    get => _showAnimation;
    set => Set(ref _showAnimation, value);
}

public ICommand OnFinishedAnimationCommand
{
    get
    {
        return new Xamarin.Forms.Command<object>(async (object sender) =>
        {
            if (sender != null)
            {
                await OnFinishedAnimation(sender);
            }
        });
    }
}

private Task OnFinishedAnimation(object sender)
{
    var view = sender as AnimationView;
    if (IsBusy)
    {
        view.PlayAnimation();
    }
    else
    {
        ShowAnimation = false;
    }
    return Task.CompletedTask;
}

In case of the Loader is related to a WebView, the ShowLoadingView property is set like this:

private Task WebViewNavigatingAsync(WebNavigatingEventArgs eventArgs)
{
    IsBusy = true;
    ShowLoadingView = true;
    return Task.CompletedTask;
}

private async Task WebViewNavigatedAsync(WebNavigatedEventArgs eventArgs)
{
    IsBusy = false;
}

But, as I also display an ErrorView in case of issues (timeout, unreachable server, ...) and a Reload/Retry button, I had to add some code:

private async Task WebViewNavigatedAsync(WebNavigatedEventArgs eventArgs)
{
    IsBusy = false;
    // for display loading animation on Refresh
    while (ShowLoadingView)
        await Task.Delay(50);
    SetServiceError();
}

In case of the Loader is related to Data loading, the ShowLoadingView property is set like this:

private async Task GetNewsAsync(bool forceRefresh = false)
{
    try
    {
        ShowErrorView = false;
        ErrorKind = ServiceErrorKind.None;
        IsBusy = true;
        ShowLoadingView = true;
        var _news = await _dataService.GetNews(forceRefresh);
        News = new ObservableCollection<News>(_news);
    }
    catch (Exception ex)
    {
        ErrorKind = ServiceErrorKind.ServiceIssue;
    }
    finally
    {
        IsBusy = false;
        await SetServiceError();
    }

}

However, I noticed that in some cases the SetServiceError() was not fired, as OnFinishedAnimation() was called in the same time. I haven't yet investigated, but I've fixed this by adding the call to SetServiceError() in in OnFinishedAnimation():

private async Task OnFinishedAnimation(object sender)
{
    var view = sender as AnimationView;
    if (IsBusy)
    {
        view.PlayAnimation();
    }
    else
    {
        ShowLoadingView = false;
        // fix SetServiceError() call issue
        await SetServiceError();
    }
}

This works as expected, but it could be simplified by using other events...

martijn00 commented 3 years ago

OnRepeatAnimation works. OnAnimationUpdate is not available on iOS because the native platform does not expose it.