Gold.strike
Gold.strike

Reputation: 1287

WebView and error management, what's wrong?

I'm working on a Xamarin.Forms app where I need to integrate a WebView to manage booking through an external URL.

So basically I've did this in my view:

<WebView x:Name="webView" Source="{Binding BookingUrl}"
         WidthRequest="1000" HeightRequest="1000">

I would like to manage some errors that the users could encounter while opening this page: no internet access, timeout, unavailable server,...

For this I've used EventToCommandBehaviorto acess to the events Navigating and Navigating in the ViewModel.

So my XAML looks like this:

<WebView x:Name="webView" Source="{Binding AvilaUrlBooking}"
            WidthRequest="1000" HeightRequest="1000">
    <WebView.Behaviors>
        <behaviors:EventToCommandBehavior
            EventName="Navigating"
            Command="{Binding NavigatingCommand}" />
        <behaviors:EventToCommandBehavior
            EventName="Navigated"
            Command="{Binding NavigatedCommand}" />
    </WebView.Behaviors>
</WebView>

And the ViewModel is like this:

public ICommand NavigatingCommand
{
    get
    {
        return new Xamarin.Forms.Command<WebNavigatingEventArgs>(async (x) =>
        {
            if (x != null)
            {
                await WebViewNavigatingAsync(x);
            }
        });
    }
}

private Task WebViewNavigatingAsync(WebNavigatingEventArgs eventArgs)
{
    if (!IsConnected)
        ServiceErrorKind = ServiceErrorKind.NoInternetAccess;

    IsBusy = true;
    return Task.CompletedTask;
}

public ICommand NavigatedCommand
{
    get
    {
        return new Xamarin.Forms.Command<WebNavigatedEventArgs>(async (x) =>
        {
            if (x != null)
            {
                await WebViewNavigatedAsync(x);
            }
        });
    }
}

private Task WebViewNavigatedAsync(WebNavigatedEventArgs eventArgs)
{
    IsBusy = false;
    IsFirstDisplay = false;
    switch (eventArgs.Result)
    {
        case WebNavigationResult.Cancel:
            // TODO - do stuff here
            break;
        case WebNavigationResult.Failure:
            // TODO - do stuff here
            break;
        case WebNavigationResult.Success:
            // TODO - do stuff here
            break;
        case WebNavigationResult.Timeout:
            // TODO - do stuff here
            break;
        default:
            // TODO - do stuff here
            break;
    }
    return Task.CompletedTask;
}

bool isFirstDisplay;
public bool IsFirstDisplay
{
    get { return isFirstDisplay; }
    set { SetProperty(ref isFirstDisplay, value); }
}

public BookingViewModel()
{
    _eventTracker = new AppCenterEventTracker();
    IsFirstDisplay = true;
    Title = "Booking";

    IsConnected = Connectivity.NetworkAccess == NetworkAccess.Internet;
    Connectivity.ConnectivityChanged += OnConnectivityChanged;
}

If I use the right URL, all works fine on iOS and Android.

However, if I use a "wrong" URL (with missing char for example), this is only working on Android: the case WebNavigationResult.Failure is catched in WebViewNavigatedAsync(), but I don't enter in WebViewNavigatedAsync() on iOS.

=> is this normal?

I've implemented a "Refresh" button to manage the "No Internet access" error. This button is accessible through a ToolBarItem, it's like this in the ViewModel:

public void Refresh(object sender)
{
    try
    {
        var view = sender as Xamarin.Forms.WebView;
        view.Reload();
    }
    catch (Exception ex)
    {
    }
}

But in these case too, I have 2 different behaviours after having activated the Airplane mode:

=> is this normal? Is there a proper way to manager this?

Upvotes: 0

Views: 1245

Answers (2)

Gold.strike
Gold.strike

Reputation: 1287

I've found another approach that seems to work, based on the following links: https://forums.xamarin.com/discussion/99790/xamarin-forms-custom-webviews-navigating-and-navigated-event-doesnt-raise-on-ios

https://gist.github.com/mrdaneeyul/9cedb3d972744de4f8752cb09024da42

It's probably not perfect, especially as I need access to the required Events from the ViewModel.

So I've created a CustomWebView control that inherits from WebView:

public class CustomWebView : WebView
{
    public static readonly BindableProperty UriProperty = BindableProperty.Create(
        propertyName: "Uri",
        returnType: typeof(string),
        declaringType: typeof(CustomWebView),
        defaultValue: default(string));

    public string Uri
    {
        get { return (string)GetValue(UriProperty); }
        set { SetValue(UriProperty, value); }
    }

    public CustomWebViewErrorKind ErrorKind { get; set; }

    public event EventHandler LoadingStart;
    public event EventHandler LoadingFinished;
    public event EventHandler LoadingFailed;

    /// <summary>
    /// The event handler for refreshing the page
    /// </summary>
    public EventHandler OnRefresh { get; set; }

    public void InvokeCompleted()
    {
        if (this.LoadingFinished != null)
        {
            ErrorKind = WebViewErrorKind.None;
            this.LoadingFinished.Invoke(this, null);
        }
    }

    public void InvokeStarted()
    {
        if (this.LoadingStart != null)
        {
            ErrorKind = WebViewErrorKind.None;
            this.LoadingStart.Invoke(this, null);
        }
    }

    public void InvokeFailed(CustomWebViewErrorKind errorKind)
    {
        if (this.LoadingFailed != null)
        {
            ErrorKind = errorKind;
            this.LoadingFailed.Invoke(this, null);
        }
    }

    /// <summary>
    /// Refreshes the current page
    /// </summary>
    public void Refresh()
    {
        OnRefresh?.Invoke(this, new EventArgs());
    }
}

Then I've the CustomWkWebViewRenderer that customizes the behavior of the CustomWebView:

[assembly: ExportRenderer(typeof(CustomWebView), typeof(CustomWkWebViewRenderer))]
namespace MyProject.iOS.Renderers
{
    public class CustomWkWebViewRenderer : ViewRenderer<CustomWebView, WKWebView>
    {

        public CustomWkWebViewRenderer()
        {
            Debug.WriteLine($"CustomWkWebViewRenderer - Ctor");
        }

        WKWebView webView;

        protected override void OnElementChanged(ElementChangedEventArgs<CustomWebView> e)
        {
            base.OnElementChanged(e);
            Debug.WriteLine($"CustomWkWebViewRenderer - OnElementChanged()");

            if (Control == null)
            {
                Debug.WriteLine($"CustomWkWebViewRenderer - OnElementChanged() - Control == null");
                webView = new WKWebView(Frame, new WKWebViewConfiguration()
                {
                    MediaPlaybackRequiresUserAction = false
                });
                webView.NavigationDelegate = new DisplayWebViewDelegate(Element);
                SetNativeControl(webView);

                Element.OnRefresh += (sender, ea) => Refresh(sender);
            }
            if (e.NewElement != null)
            {
                Debug.WriteLine($"CustomWkWebViewRenderer - OnElementChanged() - e.NewElement != null");
                Control.LoadRequest(new NSUrlRequest(new NSUrl(Element.Uri)));
                webView.NavigationDelegate = new DisplayWebViewDelegate(Element);
                SetNativeControl(webView);
            }
        }

        private void Refresh(object sender)
        {
            Debug.WriteLine($"CustomWkWebViewRenderer - Refresh()");
            Control.LoadRequest(new NSUrlRequest(new NSUrl(Element.Uri)));
        }
    }
}

I also have the CustomWkWebViewNavigationDelegate that implements the WKNavigationDelegate for this renderer:

    public class CustomWkWebViewNavigationDelegate : WKNavigationDelegate
    {
        private CustomWebView element;

        public CustomWkWebViewNavigationDelegate(CustomWebView element)
        {
            Debug.WriteLine($"CustomWkWebViewNavigationDelegate - Ctor");
            this.element = element;
        }

        public override void DidFinishNavigation(WKWebView webView, WKNavigation navigation)
        {
            Debug.WriteLine($"CustomWkWebViewNavigationDelegate - DidFinishNavigation");
            element.InvokeCompleted();
            //base.DidFinishNavigation(webView, navigation);
        }

        public override void DidStartProvisionalNavigation(WKWebView webView, WKNavigation navigation)
        {
            Debug.WriteLine($"CustomWkWebViewNavigationDelegate - DidStartProvisionalNavigation");
            element.InvokeStarted();
            //base.DidStartProvisionalNavigation(webView, navigation);
        }

        [Export("webView:didFailProvisionalNavigation:withError:")]
        public override void DidFailProvisionalNavigation(WKWebView webView, WKNavigation navigation, NSError error)
        {
            Debug.WriteLine($"CustomWkWebViewNavigationDelegate - DidFailProvisionalNavigation - error : {error}");
            var errorKind = CustomWebViewErrorKind.None;
            switch (error.Code)
            {
                case -1009: // no internet access
                    {
                        errorKind = CustomWebViewErrorKind.NoInternetAccess;
                        break;
                    }
                case -1001: // timeout
                    {
                        errorKind = CustomWebViewErrorKind.Timeout;
                        break;
                    }
                case -1003: // server cannot be found
                case -1100: // url not found on server
                default:
                    {
                        errorKind = CustomWebViewErrorKind.Failure;
                        break;
                    }
            }
            element.InvokeFailed(errorKind);
            //base.DidFailProvisionalNavigation(webView, navigation, error);
        }
    }

There is a CustomWebViewErrorKind enum that will allow me to implement a common error management in the ViewModel:

public enum CustomWebViewErrorKind
{
    None = 0,
    NoInternetAccess = 1,
    Failure = 2,
    Timeout = 3,
    Cancel = 8,
    Other = 9
}

To access to the Events from the ViewModel, I use a EventToCommandBehavior like described there

So, I've exposed all the Commands from the View like this:

<controls:CustomWebView x:Name="webView"
                        Source="{Binding MyUrlBooking}"
                        Uri="{Binding MyUrlBooking}"
                        WidthRequest="1000" HeightRequest="1000">
    <WebView.Behaviors>
        <behaviors:EventToCommandBehavior
            EventName="Navigating"
            Command="{Binding NavigatingCommand}" />
        <behaviors:EventToCommandBehavior
            EventName="Navigated"
            Command="{Binding NavigatedCommand}" />
        <behaviors:EventToCommandBehavior
            EventName="LoadingStart"
            Command="{Binding LoadingStartCommand}" />
        <behaviors:EventToCommandBehavior
            EventName="LoadingFinished"
            Command="{Binding LoadingFinishedCommand}" />
        <behaviors:EventToCommandBehavior
            EventName="LoadingFailed"
            Command="{Binding LoadingFailedCommand}"
            CommandParameter="{x:Reference webView}"
            />
    </WebView.Behaviors>
</controls:CustomWebView>

And finally, in my ViewModel I do this for the Android part:

public ICommand NavigatingCommand
{
    get
    {
        return new Xamarin.Forms.Command<WebNavigatingEventArgs>(async (x) =>
        {
            if (x != null)
            {
                await WebViewNavigatingAsync(x);
            }
        });
    }
}

private Task WebViewNavigatingAsync(WebNavigatingEventArgs eventArgs)
{
    Debug.WriteLine($"BookingViewModel - WebViewNavigatingAsync()");

    IsBusy = true;
    return Task.CompletedTask;
}

public ICommand NavigatedCommand
{
    get
    {
        return new Xamarin.Forms.Command<WebNavigatedEventArgs>(async (x) =>
        {
            if (x != null)
            {
                await WebViewNavigatedAsync(x);
            }
        });
    }
}

private Task WebViewNavigatedAsync(WebNavigatedEventArgs eventArgs)
{
    Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync()");

    IsBusy = false;
    switch (eventArgs.Result)
    {
        case WebNavigationResult.Cancel:
            Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync() - Cancel");
            ErrorKind = CustomWebViewErrorKind.Cancel;
            break;
        case WebNavigationResult.Failure:
        default:
            Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync() - Failure");
            IsConnected = Connectivity.NetworkAccess == NetworkAccess.Internet;
            if (IsConnected)
            {
                Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync() - Failure : Failure");
                ErrorKind = CustomWebViewErrorKind.Failure;
            }
            else
            if (IsConnected)
            {
                Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync() - Failure : NoInternetAccess");
                ErrorKind = CustomWebViewErrorKind.NoInternetAccess;
            }
            break;
        case WebNavigationResult.Success:
            Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync() - Success");
            ErrorKind = CustomWebViewErrorKind.None;
            IsFirstDisplay = false;
            break;
        case WebNavigationResult.Timeout:
            Debug.WriteLine($"BookingViewModel - WebViewNavigatedAsync() - Timeout");
            ErrorKind = CustomWebViewErrorKind.Timeout;
            break;
    }
    return Task.CompletedTask;
}

And I do this for the iOS part:

public ICommand LoadingStartCommand
{
    get
    {
        return new Xamarin.Forms.Command(async () =>
        {
            await WebViewLoadingStartAsync();
        });
    }
}

private Task WebViewLoadingStartAsync()
{
    Debug.WriteLine($"BookingViewModel - WebViewLoadingStartAsync()");
    IsBusy = true;
    return Task.CompletedTask;
}

public ICommand LoadingFinishedCommand
{
    get
    {
        return new Xamarin.Forms.Command(async () =>
        {
            await WebViewLoadingFinishedAsync();
        });
    }
}

private Task WebViewLoadingFinishedAsync()
{
    Debug.WriteLine($"BookingViewModel - WebViewLoadingFinishedAsync()");
    IsBusy = false;
    return Task.CompletedTask;
}

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

private Task WebViewLoadingFailedAsync(object sender)
{
    Debug.WriteLine($"BookingViewModel - WebViewLoadingFailedAsync()");
    var view = sender as CustomWebView;
    var error = view.ErrorKind;
    Debug.WriteLine($"BookingViewModel - WebViewLoadingFailedAsync() - error : {error}");
    IsBusy = false;
    return Task.CompletedTask;
}

Like this I'm able to manage errors, retry and refresh from the ViewModel, even if it's probably not the better solution...

Upvotes: 0

Leon Lu
Leon Lu

Reputation: 9234

is this normal?

Based on my test. Yes, I got the same result, if we input an error url, the webview always be white-empty view in iOS. so the NavigatedCommand cannot be executed.

If we use correct url, the webview could excute the NavigatedCommand, and running result like following screenshot.

enter image description here

Is there a proper way to manager this?

We can use custom renderer for webview in iOS to handle this situation.

[assembly: ExportRenderer(typeof(CustomWebView), typeof(CustomWebViewRenderer))]
namespace MyCarsourlView.iOS
{
    [Obsolete]
    class CustomWebViewRenderer : WebViewRenderer
    {

        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {
            base.OnElementChanged(e);

            if (e.OldElement == null) { Delegate = new CustomWebViewDelegate(); }
        }
    }
}

internal class CustomWebViewDelegate : UIWebViewDelegate
{

    #region Event Handlers

    public override bool ShouldStartLoad(UIWebView webView, NSUrlRequest request, UIWebViewNavigationType navigationType)
    {

        //Could add stuff here to redirect the user before the page loads, if you wanted to redirect the user you would do the redirection and then return false

        return true;
    }

    public override void LoadFailed(UIWebView webView, NSError error)
    {
        Console.WriteLine("\nIn AppName.iOS.CustomWebViewRenderer - Error: {0}\n", error.ToString());   //TODO: Do something more useful here
        //Here, you can test to see what kind of error you are getting and act accordingly, either by showing an alert or rendering your own custom error page using basic HTML
    }

    public override void LoadingFinished(UIWebView webView)
    { //You could do stuff here such as collection cookies form the WebView }

        #endregion
    }
}

If I input wrong url, LoadFailed could be executed.

enter image description here

Upvotes: 1

Related Questions