Search code examples
c#bootstrap-4blazorblazor-server-sidescrollspy

how to capture window.onscroll in server side blazor to imitate bootstrap scroll spy


I'm building my first server side blazor app. I'm using bootstrap css for styling. I'm not using bootstrap scripts. I have a page with fixed nav bar at top. I need to imitate bootstrap scroll spy. To do this with C# I need to monitor window.onscroll to manipulate the scroll position and apply required css classes to nav-item

Here is my HTML:

_Host.cshtml:

@page "/"
@namespace Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Learn Blazor</title>
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/animate/animate.css">
    <link rel="stylesheet" href="~/css/font-awesome/css/all.min.css" />
    <link rel="stylesheet" href="~/css/style.css">
</head>
<body data-spy="scroll" data-target=".site-nav" data-offset="55">
    <app>
        <component type="typeof(App)" render-mode="ServerPrerendered" />
    </app>

    <script src="_framework/blazor.server.js"></script>
    <script src="~/js//script.js"></script>
</body>
</html>

Index.razor:

@page "/"

<HomeSection></HomeSection>

<ColumnSection></ColumnSection>

<MediaSection></MediaSection>

Home Component:

<header id="page-hero" class="site-header">

    <TopNavMenu></TopNavMenu>

    <section class="layout-hero d-flex align-items-center text-light text-center">
        <div class="container">
            <div class="row justify-content-center">
                <div class="col-11 col-sm-10 col-md-8 animated fadeInUp">
                    <h3 class="page-section-title">Learn Blazor</h3>
                    <p class="page-section-text">
                        My First Blazor App
                    </p>
                </div>
            </div>
        </div>
    </section>

</header>

TopNavMenu Component:

@inherits TopNavMenuBase

<nav class="site-nav family-sans text-uppercase navbar navbar-expand-md navbar-dark fixed-top" @ref="headerNav">

    <div class="container-fluid">

        <NavLink class="navbar-brand" href="#page-hero" @onclick="NavigateToElementAsync">
            <i class="fas fa-microphone-alt"></i> The Prodcast
        </NavLink>

        <button type="button" class="navbar-toggler @NavToggleCssClass" @onclick="ToggleNavMenu" aria-controls="#myTogglerNav"
                aria-label="Toggle Navigation" aria-expanded="@($"{!collapseNavMenu}".ToLowerInvariant())">
            <span class="navbar-toggler-icon"></span>
        </button>

        <section class="navbar-collapse collapse @NavMenuCssClass" id="myTogglerNav">
            <div class="navbar-nav ml-auto">
                <NavLink class="nav-item nav-link" href="#page-hero" @onclick="NavigateToElementAsync">home</NavLink>
                <NavLink class="nav-item nav-link" href="#page-multicolumn" @onclick="NavigateToElementAsync">columns</NavLink>
                <NavLink class="nav-item nav-link" href="#page-media" @onclick="NavigateToElementAsync">media</NavLink>
               </div>
        </section>

    </div>

</nav>

Here is my ComponentBase:

public class TopNavMenuBase : ComponentBase
{
    protected ElementReference headerNav;
    protected bool collapseNavMenu = true;
    protected string NavToggleCssClass => collapseNavMenu ? "collapsed" : null;
    protected string NavMenuCssClass => !collapseNavMenu ? "show" : null;

    [Inject]
    public IJSRuntime JSRuntime { get; set; }

    [Inject]
    public NavigationManager NavigationManager { get; set; }

    protected void ToggleNavMenu() => collapseNavMenu = !collapseNavMenu;

    protected override async Task OnAfterRenderAsync(bool firstRender) => await NavigateToElementAsync();

    protected async Task NavigateToElementAsync()
    {
        var fragment = new Uri(NavigationManager.Uri).Fragment;
        var elementId = !string.IsNullOrEmpty(fragment) && fragment.StartsWith("#") ? fragment.Substring(1) : fragment;

        await JSRuntime.InvokeAsync<bool>("scrollToElementId", elementId, headerNav);
    }
}

And finally my script:

Edit 1: Updated complete working window.onscroll to make scroll spy using pure javascript

function scrollToElementId(target, headerNav) {
    var topoffset = 55;
    var element = document.getElementById(target);
    if (element) {
        const bodyRect = document.body.getBoundingClientRect().top;
        const elementRect = element.getBoundingClientRect().top;
        const elementPosition = elementRect - bodyRect;
        const offsetPosition = elementPosition - topoffset;

        window.scrollTo({
            top: offsetPosition,
            behavior: 'smooth'
        });

        headerNav.classList.toggle('inbody', target !== 'page-hero');

        return true;
    }

    document.querySelector('a.nav-link[href="#page-hero"]').classList.toggle('active', target === '');

    return false;
}

var sections = {};

document.querySelectorAll(".page-section,.site-header").forEach(section => sections[section.id] = section.offsetTop);

window.onscroll = function () {
    document.querySelector('header nav').classList.toggle('inbody', document.documentElement.scrollTop > 380);
    document.querySelector('#page-media .layout-animation').style['visibility'] = 'hidden';

    var scrollPosition = document.documentElement.scrollTop || document.body.scrollTop;

    Object.keys(sections).forEach(key => {
        if (sections[key] <= scrollPosition) {
            document.querySelectorAll('a.nav-item').forEach(a => a.classList.remove('active'));
            document.querySelector('a[href="#' + key + '"]').classList.add('active');

            if (key === 'page-media') {
                document.querySelector('#page-media .layout-animation').classList.add('animated', 'fadeInRight');
            }
        }
    });
};

The above code setup works. But I feel like capturing window.onscroll using javascript is not the ideal solution. With server side Blazor, I would like to do this with C#. But how to capture this window.onscroll with @onscroll ? Where do I need to hook this @onscroll. The body tag is in _Host.cshtml and TopNavMenu component is nested inside Home component which is a rendered from index.razor file.

Can anyone assist me on how to make this work properly with Blazor server side? Please correct me if I'm wrong.


Solution

  • I finally made this to work using IntersectionObserver in javascript.

    I replaced window.onscroll function as it is nasty in performance with highlightMenu() function

    function highlightMenu() {
        const sections = document.querySelectorAll('.page-section,.site-header');
        const config = {
            rootMargin: '-55px 0px -85%'
        };
    
        let observer = new IntersectionObserver(function
            (entries, self) {
    
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    intersectionHandler(entry);
                }
            });
        }, config);
    
        sections.forEach(section => observer.observe(section));
    }
    
    function intersectionHandler(entry) {
        const id = entry.target.id;
        document.querySelector('header nav').classList.toggle('inbody', id !== 'page-hero');
        if (id === 'page-media') {
            document.querySelector('#page-media .layout-animation').classList.add('animated', 'fadeInRight');
        }
        const currentlyActive = document.querySelector('nav a.nav-item.active');
        const shouldBeActive = document.querySelector('nav a.nav-item[href="#' + id + '"]');
    
        if (currentlyActive) {
            currentlyActive.classList.remove('active');
        }
        if (shouldBeActive) {
            shouldBeActive.classList.add('active');
        }
    }
    

    and then I called this function inside OnAfterRenderAsync from Index.Razor,

    @code{
    
        protected override async Task OnAfterRenderAsync(bool firstRender)
        {
            if (firstRender)
            {
                await JSRuntime.InvokeVoidAsync("highlightMenu");
            }
        }
    
    }
    

    it worked like a bootstrap scroll spy. Hope this helps someone.