asp.net-corerazor-pagesdevice-detection

How do I use IViewLocationExtender with Razor Pages to render device specific pages


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:

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


Solution

  • 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()));
                });