1

Currently we are building a web application, desktop first, that needs device specific Razor Pages for specific pages. Those pages are really different from their Desktop version and it makes no sense to use responsiveness here.

We have tried to implement our own IViewLocationExpander and also tried to use the MvcDeviceDetector library (which is basically doing the same). Detection of the device type is no problem but for some reason the device specific page is not picked up and it is constantly falling back to the default Index.cshtml. (edit: We're thinking about implementing something based on IPageConvention, IPageApplicationModelProvider or something ... ;-))

Index.mobile.cshtml Index.cshtml

We have added the following code using the example of MvcDeviceDetector:

public static IMvcBuilder AddDeviceDetection(this IMvcBuilder builder)
{
    builder.Services.AddDeviceSwitcher<UrlSwitcher>(
    o => { },
    d => {
            d.Format = DeviceLocationExpanderFormat.Suffix;
            d.MobileCode = "mobile";
        d.TabletCode = "tablet";
    }
    );

    return builder;
}

and are adding some route mapping

routes.MapDeviceSwitcher();

We expected to see Index.mobile.cshtml to be picked up when selecting a Phone Emulation in Chrome but that didnt happen.

edit Note:

  • we're using a combination of Razor Views/MVC (older sections) and Razor Pages (newer sections).
  • also not every page will have a mobile implementation. That's what would have a IViewLocationExpander solution so great.

edit 2 I think the solution would be the same as how you'd implement Culture specific Razor Pages (which is also unknown to us ;-)). Basic MVC supports Index.en-US.cshtml

Final Solution Below

alex.pino
  • 225
  • 2
  • 9
  • Can you clarify whether you are using Razor Pages or MVC? – Mike Brind Jul 16 '19 at 06:53
  • We're actually using both. A new section of the site uses Razor Pages, and the older section is using 'old skool' Razor Views. And most likely in the future sections will start using Blazor. Reason: because you can and MVC allows you to ;-) But this problem is about Razor Pages, the MVC routing is fine. – alex.pino Jul 17 '19 at 07:56

2 Answers2

1

If this is a Razor Pages application (as opposed to an MVC application) I don't think that the IViewLocationExpander interface is much use to you. As far as I know, it only works for partials, not routeable pages (i.e. those with an @page directive).

What you can do instead is to use Middleware to determine whether the request comes from a mobile device, and then change the file to be executed to one that ends with .mobile. Here's a very rough and ready implementation:

public class MobileDetectionMiddleware
{
    private readonly RequestDelegate _next;

    public async Task Invoke(HttpContext context)
    {
        if(context.Request.IsFromAMobileDevice())
        {
            context.Request.Path = $"{context.Request.Path}.mobile";
        }
        await _next.Invoke(context);
    }
}

It's up to you how you want to implement the IsFromAMobileDevice method to determine the nature of the user agent. There's nothing stopping you using a third party library that can do the check reliably for you. Also, you will probably only want to change the path under certain conditions - such as where there is a device specific version of the requested page.

Register this in your Configure method early:

app.UseMiddleware<MobileDetectionMiddleware>();
Mike Brind
  • 28,238
  • 6
  • 56
  • 88
  • I'm wondering if this will work. As this solution suggests that every page should have a mobile version, which is not the case. Only specific pages (I should've written that in the question) – alex.pino Jul 17 '19 at 07:58
  • 1
    As I said in my answer, you might want to apply a condition to the code that changes the path - only in situations where the requested page has a dedicated mobile version. It is a basic implementation designed to illustrate the concept only. – Mike Brind Jul 17 '19 at 08:27
1

I've finally found the way to do it convention based. I have implemented a IViewLocationExpander in order to tackle the device handling for basic Razor Views (including Layouts) and I've implemented IPageRouteModelConvention + IActionConstraint to handle devices for Razor Pages.

Note: this solution only seems to be working on ASP.NET Core 2.2 and up though. For some reason 2.1.x and below is clearing the constraints (tested with a breakpoint in a destructor) after they've been added (can probably be fixed).

Now I can have /Index.mobile.cshtml /Index.desktop.cshtml etc. in both MVC and Razor Pages.

Note: This solution can also be used to implement a language/culture specific Razor Pages (eg. /Index.en-US.cshtml /Index.nl-NL.cshtml)

public class PageDeviceConvention : IPageRouteModelConvention
{
    private readonly IDeviceResolver _deviceResolver;

    public PageDeviceConvention(IDeviceResolver deviceResolver)
    {
        _deviceResolver = deviceResolver;
    }

    public void Apply(PageRouteModel model)
    {
        var path = model.ViewEnginePath; // contains /Index.mobile
        var lastSeparator = path.LastIndexOf('/');
        var lastDot = path.LastIndexOf('.', path.Length - 1, path.Length - lastSeparator);

        if (lastDot != -1)
        {
            var name = path.Substring(lastDot + 1);

            if (Enum.TryParse<DeviceType>(name, true, out var deviceType))
            {
                var constraint = new DeviceConstraint(deviceType, _deviceResolver);

                 for (var i = model.Selectors.Count - 1; i >= 0; --i)
                {
                    var selector = model.Selectors[i];
                    selector.ActionConstraints.Add(constraint);

                    var template = selector.AttributeRouteModel.Template;
                    var tplLastSeparator = template.LastIndexOf('/');
                    var tplLastDot = template.LastIndexOf('.', template.Length - 1, template.Length - Math.Max(tplLastSeparator, 0));

                    template = template.Substring(0, tplLastDot); // eg Index.mobile -> Index
                    selector.AttributeRouteModel.Template = template;

                    var fileName = template.Substring(tplLastSeparator + 1);
                    if ("Index".Equals(fileName, StringComparison.OrdinalIgnoreCase))
                    {
                        selector.AttributeRouteModel.SuppressLinkGeneration = true;
                        template = selector.AttributeRouteModel.Template.Substring(0, Math.Max(tplLastSeparator, 0));
                        model.Selectors.Add(new SelectorModel(selector) { AttributeRouteModel = { Template = template } });
                    }
                }
            }
        }
    }

    protected class DeviceConstraint : IActionConstraint
    {
        private readonly DeviceType _deviceType;
        private readonly IDeviceResolver _deviceResolver;

        public DeviceConstraint(DeviceType deviceType, IDeviceResolver deviceResolver)
        {
            _deviceType = deviceType;
            _deviceResolver = deviceResolver;
        }

        public int Order => 0;

        public bool Accept(ActionConstraintContext context)
        {
            return _deviceResolver.GetDeviceType() == _deviceType;
        }
    }
}

public class DeviceViewLocationExpander : IViewLocationExpander
{
    private readonly IDeviceResolver _deviceResolver;
    private const string ValueKey = "DeviceType";

    public DeviceViewLocationExpander(IDeviceResolver deviceResolver)
    {
        _deviceResolver = deviceResolver;
    }

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        var deviceType = _deviceResolver.GetDeviceType();

        if (deviceType != DeviceType.Other)
            context.Values[ValueKey] = deviceType.ToString();
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        var deviceType = context.Values[ValueKey];
        if (!string.IsNullOrEmpty(deviceType))
        {
            return ExpandHierarchy();
        }

        return viewLocations;

        IEnumerable<string> ExpandHierarchy()
        {
            var replacement = $"{{0}}.{deviceType}";

            foreach (var location in viewLocations)
            {
                if (location.Contains("{0}"))
                    yield return location.Replace("{0}", replacement);

                yield return location;
            }
        }
    }
}

public interface IDeviceResolver
{
    DeviceType GetDeviceType();
}

public class DefaultDeviceResolver : IDeviceResolver
{
    public DeviceType GetDeviceType() => DeviceType.Mobile;
}

public enum DeviceType
{
    Other,
    Mobile,
    Tablet,
    Normal
}

Startup

services.AddMvc(o => { })
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2)
            .AddRazorOptions(o =>
            {
                o.ViewLocationExpanders.Add(new DeviceViewLocationExpander(new DefaultDeviceResolver()));
            })
            .AddRazorPagesOptions(o =>
            {
                o.Conventions.Add(new PageDeviceConvention(new DefaultDeviceResolver()));
            });
alex.pino
  • 225
  • 2
  • 9