Theming in ASP.NET Core

Jun 05, 2017     Viewed 1869 times    6 Comments
Posted in #Themes  #Theming 

Themes allow you to define the look and feel of your pages and then apply the look consistently across pages in a web application. In previous versions of ASP.NET specifically WebForms we already have the concept of Themes & Skins which give us the ability to change the look and feel of controls & pages within the web application. Unfortunately we can't use this because the WebForms is not supported yet in ASP.NET Core.

In this blog post I wanna explore how can we apply the theming in ASP.NET Core.

ViewLocationExpanders

The view location expanders is one of those concepts that perhaps not known to many of us, ASP.NET Core provide us with a simple contract named IViewLocationExpander which is basically used to determine searched paths for a view.

If I'm not wrong ASP.NET out-of-the-box have two implementations for this contract, PageViewLocationExpander which is used for determine the RazorPages path, and LanguageViewLocationExpander which is responsible for determine the path of the views specially when we 're using localization.

In this post seems the ViewLocationExpander is the right option to apply the theming in any ASP.NET Core web application, the main idea here is to tweak the view location paths to let the Razor engine looks to the themes folder instead of the default view locations.

Let us dig into that ...

First of all we need to create AppSettings section in the appSettings.json and add the Theme property to hold the default theme name that will be used for our web application.

{
  "AppSettings": {
    "Theme": "LightTheme"
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  }
}

Then we need to create IViewLocationExpander implementation to set the view locations that the razor engine will looks for all the views.

public class ThemeViewLocationExpander : IViewLocationExpander
{
    private const string ValueKey = "theme";

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (viewLocations == null)
        {
            throw new ArgumentNullException(nameof(viewLocations));
        }

        context.Values.TryGetValue(ValueKey, out string theme);

        if (!string.IsNullOrEmpty(theme))
        {
            return ExpandViewLocationsCore(viewLocations, theme);
        }

        return viewLocations;
    }

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        var appSettings = context.ActionContext.HttpContext.RequestServices
            .GetService(typeof(IOptions<AppSettings>)) as IOptions<AppSettings>;

        context.Values[ValueKey] = appSettings.Value.Theme;
    }

    private IEnumerable<string> ExpandViewLocationsCore(IEnumerable<string> viewLocations, string theme)
    {
        foreach (var location in viewLocations)
        {
            yield return location.Insert(7, $"Themes/{theme}/");
        }
    }
}

So if you notice in the code snippet before in the ExpandViewLocations() method that I'm inserting the Themes/{ThemeName} in the position 7 of current view location, which is mainly after the /Views/ token, so that means the razor engine will no longer looking for the views into the default locations, instead it will look inside the theme location.

After that in the ConfigureServices() method we need to bind the AppSettings section from the configuration file to the AppSettings class also we need to add the ThemeViewLocationExpander that I created before to the ViewLocationExpanders property of the RazorViewEngineOptions.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<AppSettings>(Configuration.GetSection("AppSettings"));

    services.AddMvc();

    services.Configure<RazorViewEngineOptions>(options =>
    {
        options.ViewLocationExpanders.Add(new ThemeViewLocationExpander());
    });
}

The last trick I made in the Configure() method to let the static files be serving from the root by using the StaticFileOptions with setting RequestPath property to empty string

var appSettings = app.ApplicationServices.GetRequiredService<IOptions<AppSettings>>();

app.UseStaticFiles(new StaticFileOptions()
{
    FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(),
        $@"Views/Themes/{appSettings.Value.Theme}")),
    RequestPath = string.Empty
});

Finally when you run the application the themes should look like the following:

Light Theme

Dark Theme

You can download the source code for this post from my Theming repository on GitHub.

Twitter Facebook Google + LinkedIn


6 Comments

Muhammad Rehan Saeed (6/7/2017 12:56:50 AM)

Consider using CSS Variables. They make theming very easy and have decent browser support these days.

Mark Rendle (6/7/2017 2:48:59 AM)

Please, don't do this. Themes are what CSS is for. You don't need to create multiple copies of identical Razor views just to change the appearance of things in the browser. Just provide different CSS files (with related image sets or whatever) and use an app setting or user preference to generate the link tag.

Hisham Bin Ateya (6/7/2017 5:29:03 PM)

Thanks @Muhammad for your comment

Hisham Bin Ateya (6/7/2017 5:32:19 PM)

@Mark I knew that providing different styles is enough, but consider the case that you need to change the layout not only the colors & fonts. So that's why I planned to improved the code ASAP

Michael Esteves (6/7/2017 11:19:51 PM)

I think this is also a interesting way to have an old and new version of your site running side by side allowing the user to pick which one they want while the new version is in development. So not just css changes but layout changes.

You might be doing a total site redesign. Its not feasible to complete the entire redesign before giving it to users so you do it section by section. Allow the user to pick that they want to see the new site where applicable. The code falls back if the new versions doesn't exist. If its not feature complete they can still swap to the old version.

Hisham Bin Ateya (6/8/2017 2:35:23 PM)

Thanks @Michael, I didn't think about that


Leave a Comment