senäh

17senäh und so…

stickyheaders_artikelbild

HTML/CSS/JS
05. Mai 2013
Kommentare: 0

stickyHeaders Code Walkthrough

Kategorien: HTML/CSS/JS | 05. Mai 2013 | Kommentare: 0

Vor 2 Wochen hatte ich mein jQuery Plugin stickyHeaders vorgestellt. senäh wäre nicht senäh, wenn es dazu nicht auch ein Tutorial gäbe, das aufzeigt, wie das Plugin Schritt für Schritt nachzubauen ist. Here we go ;)

HTML-Grundgerüst

Wir brauchen mehrere Überschriften, natürlich mit Inhalt dazwischen. Dazu noch einen Wrapper, der das ganze umfasst. Außerdem haben wir vermutlich sowohl Inhalt darüber (z.B. Navi, Header, Logo, etc.) als auch darunter (z.B. Footer). Also ungefähr so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- Content vorher, z.B. Navi, Header, Logo, etc. -->
<h1>Lorem ipsum dolor sit amet.</h1>

<div class="sticky-header-wrap">

    <h2 class="sticky-header">Eine Überschrift</h2>
    <!-- Content -->
    <!-- mehr Content -->
    <!-- noch mehr Content -->

    <h2 class="sticky-header">Noch eine Überschrift</h2>
    <!-- Content -->
    <!-- mehr Content -->
    <!-- noch mehr Content -->

    <!-- noch mehr Überschriften mit Content -->

</div>

<!-- Content danach, z.B. Footer -->
<footer>Lorem ipsum dolor sit amet.</footer>

Das zugehörige CSS ist auch recht simpel gehalten:

1
2
3
.sticky-header-hidden {visibility:hidden;}
.sticky-header-wrap {position:relative;}
h2.sticky-helper {position:absolute; margin-top:0;}

Wir benötigen eine Klasse, um die Überschriften zu verstecken. Außerdem wird der Wrapper absolut positioniert, damit wir einen gemeinsamen Bezugspunkt haben, wenn wir die .sticky-header absolut positionieren. Nicht zu vergessen ist außerdem die Klasse für die Überschrift an sich. Dazu gleich mehr.

Das h2 vor der Klasse .sticky-helper ist eventuell nicht notwendig. In meinen Tests und Demos musste ich aber stets darauf zurückgreifen. Sollte euch das nicht zusagen, lasst es halt weg ;)

Wozu die Klassen im Einzelnen dienen? Bitte weiterlesen ;)

Prinzip

Bei jedem Scroll-Event überprüfen wir, ob die Anfangsposition einer Überschrift überschritten wurde. Falls ja, muss diese Überschrift sticky (d.h. position:fixed) werden. So weit, so gut.

Spannend wird es beim Übergang von einer Überschrift zur Nächsten. Die Situation vor dem Übergang ist wie folgt:

2 Überschriften vor dem Übergang

2 Überschriften vor dem Übergang

Oben ist der aktuelle sticky Header (A). Wir scrollen in Richtung B. Sobald die Unterkante von A die Oberkante von B erreicht, ist A nicht mehr sticky. A muss daraufhin absolut positioniert werden und zwar an der Position Oberkante von B – Höhe von A, damit es wieder mitscrollt.

Verlauf der Positionierung während des Übergangs

Verlauf der Positionierung während des Übergangs

Sobald A nicht mehr zu sehen ist, muss B der neue sticky Header werden.

.sticky-helper als Zusatzelement

Technisch gesehen arbeiten wir mit einem Zusatzelement (.sticky-helper), statt die eigentlichen Überschriften (.sticky-header) zu positionieren. Warum? Damit wir die eigentlichen Überschriften nicht umpositionieren müssen, was Sprünge im Layout nach sich ziehen würde.

Sobald eine Überschrift also sticky werden muss, wird die eigentliche Überschrift versteckt (.sticky-header-hidden) und der .sticky-helper kommt zum Vorschein. Er erhält den Text der eigentlichen Überschrift und wird auf position:fixed und top:0 gesetzt.

Beim Übergang wird der .sticky-helper dann wie oben bereits beschrieben absolut positioniert, um das “Wegschieben” der alten Überschrift zu realisieren. Wenn das Wegschieben von A erfolgt und nur noch B zu sehen ist, wird der .sticky-helper wieder position:fixed und bekommt den Text von B. Und so weiter und so fort.

Das Plugin

Wenden wir uns nun der eigentlichen Realisierung im Plugin zu.

Variablen

Fangen wir an mit ein paar Variablen an. Die Kommentare sollten für das Verständnis ausreichen.

Hinweis: Der Einfachheit halber werde ich das this. jeweils weglassen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// jQuery Objects
$headers       = null;

// custom Objects consisting of jQuery Object, height,
// top offset and text
headers        = [];

// header container count
headerLength   = 0;

// sticky container
$sticky         = null;

// sticky container text wrapper
$stickyContent  = null;

// position of wrapper for all headers
navWrapPos     = 0;

// position where sticky-scrolling should end
endOfScrollPos = null;

// status container
lastStatus     = {
    isFixed: false,
    index:   0
};

Elemente und Events zuweisen

Der ständig erwähnte .sticky-helper muss noch hinzugefügt werden und zwar dem Container, der das Plugin aufruft ($this). Außerdem füllen wir den Helper mit einem span, der einerseits für das Styling behilflich sein könnte und andererseits als Text-Wrapper dient.

1
2
3
4
5
6
$sticky = $('<h2 />')
    .wrapInner('<span />')
    .addClass('sticky-helper sticky-header-hidden')
    .prependTo($this);

$stickyText = $sticky.find('span');

Weiterhin müssen wir

  • alle Überschriften durchgehen und ein paar Werte und Positionen cachen,
  • diesen Cache aktualisieren, sobald alle Bilder geladen sind, da sich die Positionen der Überschriften verändert haben könnten, und
  • beim Scrollen schauen, ob sich der sticky Header ändern muss.

In Methoden gekapselt sehen die Aufrufe erstmal so aus:

1
2
3
4
5
6
7
buildHeaderCache();

$(window).on('load stickyHeadersRebuildCache', function() {
    buildHeaderCache();
}).on('scroll touchmove', function() {
    updateSticky();
});

Das Event stickyHeadersRebuildCache kann vom Benutzer manuell getriggert werden, um den Cache neu zu erstellen, falls sich der Content zwischen den Überschriften geändert hat. Das touchmove-Event wird mitaufgenommen, da es auf iOS-Geräten öfter getriggert wird, also das scroll-Event.

Überschriften cachen

Wie bereits erwähnt sollten Werte wie Position, Höhe, Text und das jQuery Objekt der Überschriften gecached werden, um schneller darauf zugreifen zu können. Genau das geschieht in der Methode buildHeaderCache():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function buildHeaderCache() {
    navWrapPos   = $this.offset().top;
    $headers     = $('.sticky-header');
    headerLength = $headers.length;

    headers = [];
    $headers.each(function(i, el) {
        var $el = $(el);
        headers.push({
            $el:    $el,
            height: $el.outerHeight(),
            pos:    $el.offset().top,
            text:   $el.text()
        });
    });

    endOfScrollPos = $('footer').offset().top;
}

Prüfen, ob sich sticky Header geändert hat

Das Grundgerüst der updateSticky-Funktion sieht so aus:

1
2
3
4
5
6
function updateSticky() {
    var scrollPos = window.scrollY,
        i         = 0; // counter for header elements

    // ...
}

Grundsätzlich gibt es 4 Zustände. Wir befinden uns

  1. oberhalb der ersten Überschrift
  2. unterhalb der letzten Überschrift
  3. im Übergang zwischen 2 Überschriften
  4. irgendwo zwischen 2 Überschriften

Zuerst prüfen wir die ersten beiden Bedingungen:

1
2
3
4
5
6
7
8
9
10
// are we above first header?
if (scrollPos < headers[i].pos) {
    disableSticky(headers[i].pos, i);

// are we below last header?
else if (!!endOfScrollPos && scrollPos > endOfScrollPos - headers[headerLength - 1].height)
    disableSticky(
        endOfScrollPos - headers[headerLength - 1].height,
        headerLength - 1
    );

Zur konkreten Implementierung der disableSticky-Funktion komme ich später. Sie sorgt dafür, dass der sticky Header anhand der übergebenen Parameter absolut positioniert wird (in diesem Fall für Zustand 1 ganz oben anstelle der ersten Überschrift, für Zustand 2 ganz unten kurz vor Ende des Footers).

Um Zustand 3 und 4 zu überprüfen, durchlaufen wir den Header-Cache rückwärts. Warum rückwärts? Kurz gesagt: es vereinfacht die if-Statements, da man nachfolgende Elemente nicht berücksichtigen muss.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var updateComplete = false;

for (i = headerLength - 1; !updateComplete; i--) {

    // are we in transition between 2 headers?
    if (i + 1 < headerLength && scrollPos + headers[i].height >= headers[i+1].pos) {
        disableSticky(headers[i+1].pos - headers[i].height, i);
        updateComplete = true;

    // are we below current header?
    } else if (scrollPos >= headers[i].pos) {
        enableSticky(i);
        updateComplete = true;
    }
}

Mithilfe der updateComplete-Variable durchlaufen wir die Schleife so lang, bis wir herausgefunden haben, an welcher Stelle der sticky Header positioniert werden muss. Wenn wir uns im Übergang befinden, muss er – wie oben bereits erwähnt – absolut positioniert werden und zwar auf den Wert Oberkante von B – Höhe von A (siehe Zeile 7).

Befindet sich die Scrollposition unterhalb der gerade untersuchten Überschrift (headers[i]), muss der sticky Header position:fixed gesetzt werden. Realisiert wird das in der enableSticky-Methode.

enableSticky()

Beim Aktivieren des sticky Headers müssen wir:

  • die .sticky-header-hidden-Klasse entfernen, falls unser Helper noch versteckt ist,
  • die Klasse .is-sticky hinzufügen (für Styling-Zwecke),
  • position:fixed und top:0 setzen,
  • den Text der aktuellen Überschrift übernehmen und
  • alle Überschriften sichtbar machen, außer der Aktuellen.

Die letzten beiden Schritte sind in einer weiteren Methode (updateTextAndClassesAndStatus) gekapselt, da wir den Block so ähnlich auf bei der disableSticky-Funktion benötigen. In Codeform also:

1
2
3
4
5
6
7
8
9
10
11
function enableSticky(currentIndex) {
    $sticky
        .removeClass('sticky-header-hidden')
        .addClass('is-sticky')
        .css({
            position: 'fixed',
            top:      0
    });

    updateTextAndClassesAndStatus(currentIndex, true);
}

disableSticky()

Das Gegenstück zu enableSticky() vollzieht ähnliche Aufgaben, nur dass

  • die Klasse .is-sticky entfernt statt hinzugefügt und
  • position:absolute und top anhand der übergebenen Parameter gesetzt wird.
1
2
3
4
5
6
7
8
9
10
function disableSticky(targetPosition, currentIndex) {
    $sticky
        .removeClass('sticky-header-hidden is-sticky')
        .css({
            position: 'absolute',
            top:      targetPosition - navWrapPos
        });

    updateTextAndClassesAndStatus(currentIndex, false);
}

updateTextAndClassesAndStatus()

In dieser Methode werden alle Überschriften außer der aktuellen angezeigt und der Text des sticky Headers aktualisiert.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function updateTextAndClassesAndStatus(currentIndex, isFixed) {
    $stickyText.text(headers[currentIndex].text);

    $headers
        .not(headers[currentIndex].$el)
        .removeClass(options.hiddenClass);

    headers[currentIndex].$el.addClass(options.hiddenClass);

    lastStatus = {
        isFixed: isFixed,
        index:   currentIndex
    };
}

Was ist das ganz unten? lastStatus? Bitte weiterlesen ;)

Performance

Damit wir den sticky Header nicht updaten, wenn sich nichts geändert hat, speichern wir 2 Aspekte:

  1. ob der sticky Header gerade position:fixed ist oder nicht und
  2. den Index der aktuellen Überschrift.

Dadurch können wir bei den 4 Zuständen von oben immer prüfen, ob sich überhaupt etwas geändert hat, z.B. für Zustand 1:

1
2
3
4
5
6
7
// are we above first header?
if (scrollPos < headers[i].pos) {
    if (lastStatus.index == i && !lastStatus.isFixed)
        return;

    disableSticky(headers[i].pos, i);
}

Das fertige Plugin

Noch ein paar typische jQuery-Plugin-Wrapper inspiriert von jQuery-Boilerplate drum und das ganze sieht so aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
;(function ($) {

    // DEFAULTS
    // -------------------------------------------------------------------------

    var pluginName = 'stickyHeaders',
        defaults = {
            headlineSelector:      '.sticky-header',
            hiddenClass:           'sticky-header-hidden',
            stickyElement:         'h2',
            stickyClass:           'sticky-helper',
            stickyChildren:        '<span></span>',
            textContainerSelector: 'span',
            endOfScrollPos:        null
        };

    // CONSTRUCTOR
    // -------------------------------------------------------------------------

    function StickyHeaders(el, options) {
        this.$el            = $(el);
        this.options        = $.extend({}, defaults, options);

        // jQuery Objects
        this.$headers       = null;

        // custom Objects consisting of jQuery Object, height, top offset and
        // text
        this.headers        = [];

        // header container count
        this.headerLength   = 0;

        // sticky container
        this.$sticky         = null;

        // sticky container text wrapper
        this.$stickyText    = null;

        // position of wrapper for all headers
        this.navWrapPos     = 0;

        // position where sticky-scrolling should end
        this.endOfScrollPos = null;

        // status container
        this.lastStatus     = {
            isFixed: false,
            index:   0
        };

        // let's get it on
        this.init();
    }

    // PROTOTYPE
    // -------------------------------------------------------------------------

    StickyHeaders.prototype = {

        // create sticky container and bind events
        init: function() {
            this.$sticky     = $('<' + this.options.stickyElement + ' />')
                                   .wrapInner(this.options.stickyChildren)
                                   .addClass(this.options.stickyClass + ' ' + this.options.hiddenClass)
                                   .prependTo(this.$el);
            this.$stickyText = this.$sticky.find(this.options.textContainerSelector);
            this.buildHeaderCache();

            var _this = this;
            $(window).on('load stickyHeadersRebuildCache', function() {
                _this.buildHeaderCache();
            }).on('scroll touchmove', function() {
                _this.updateSticky();
            });
        },

        // update sticky on scoll
        updateSticky: function() {
            var scrollPos = window.scrollY,
                i         = 0;

            // are we above first header?
            if (scrollPos < this.headers[i].pos) {
                if (this.lastStatus.index == i && !this.lastStatus.isFixed)
                    return;

                this.disableSticky(this.headers[i].pos, i);

            // are we below last header?
            } else if (!!this.endOfScrollPos && scrollPos > this.endOfScrollPos - this.headers[this.headerLength - 1].height) {
                if (this.lastStatus.index == this.headerLength - 1 && !this.lastStatus.isFixed)
                    return;

                this.disableSticky(
                    this.endOfScrollPos - this.headers[this.headerLength - 1].height,
                    this.headerLength - 1
                );

            // we are between start of first and end of last header
            } else {
                var updateComplete = false;

                for (i = this.headerLength - 1; !updateComplete; i--) {

                    // are we in transition between 2 headers?
                    if (i + 1 < this.headerLength && scrollPos + this.headers[i].height >= this.headers[i+1].pos) {
                        if (this.lastStatus.index == i && !this.lastStatus.isFixed)
                            return;

                        this.disableSticky(this.headers[i+1].pos - this.headers[i].height, i);
                        updateComplete = true;

                    // are we below current header?
                    } else if (scrollPos >= this.headers[i].pos) {
                        if (this.lastStatus.index == i && this.lastStatus.isFixed)
                            return;

                        this.enableSticky(i);
                        updateComplete = true;
                    }
                }
            }
        },

        // make sticky container fixed and position it at top
        enableSticky: function(currentIndex) {
            this.$sticky
                .removeClass(this.options.hiddenClass)
                .addClass('is-sticky')
                .css({
                    position: 'fixed',
                    top:      0
            });

            this.updateTextAndClassesAndStatus(currentIndex, true);
        },

        // make sticky container absolute and position it accordingly
        disableSticky: function(targetPosition, currentIndex) {
            this.$sticky
                .removeClass(this.options.hiddenClass + ' is-sticky')
                .css({
                    position: 'absolute',
                    top:      targetPosition - this.navWrapPos
                });

            this.updateTextAndClassesAndStatus(currentIndex, false);
        },

        // when positioning is done: update sticky container text, header
        // classes and status
        updateTextAndClassesAndStatus: function(currentIndex, isFixed) {
            this.$stickyText.text(this.headers[currentIndex].text);

            this.$headers
                .not(this.headers[currentIndex].$el)
                .removeClass(this.options.hiddenClass);

            this.headers[currentIndex].$el.addClass(this.options.hiddenClass);

            this.lastStatus = {
                isFixed: isFixed,
                index:   currentIndex
            };
        },

        // recalculate header element's positions etc.
        buildHeaderCache: function() {
            var _this = this;

            this.navWrapPos   = this.$el.offset().top;
            this.$headers     = $(this.options.headlineSelector)
                                    .not("." + this.options.stickyClass);
            this.headerLength = this.$headers.length;

            this.headers = [];
            this.$headers.each(function(i, el) {
                var $el = $(el);
                _this.headers.push({
                    $el:    $el,
                    height: $el.outerHeight(),
                    pos:    $el.offset().top,
                    text:   $el.text()
                });
            });

            // fix for wrong endOfScrollPos when images take too long to render
            if (!this.endOfScrollPos && !!this.options.endOfScrollPos)
                this.endOfScrollPos = typeof this.options.endOfScrollPos == 'function'
                    ? this.options.endOfScrollPos()
                    : this.options.endOfScrollPos;
        }
    };

    // REGISTER PLUGIN
    // -------------------------------------------------------------------------

    $.fn[pluginName] = function (options) {
        return this.each(function () {
            if (!$.data(this, 'plugin_' + pluginName)) {
                $.data(
                    this, 'plugin_' + pluginName,
                    new StickyHeaders(this, options)
                );
            }
        });
    };

})(jQuery);

So, das war’s mit dem Walkthrough. Das Plugin findet sich übrigens auch bei GitHub. Solltet ihr Anregungen und/oder Fragen haben: immer her damit ;)

Autor: Enno

Ich bin Enno. PHP ist mein Ding, aber auch alles Neue rund um die Themen HTML5, CSS3 & Co finde ich interessant. Ich mag es Leuten zu helfen und mein Wissen weiterzugeben. Sollte dir mein Beitrag gefallen haben, lass doch nen Kommentar da oder benutze einen der Social Buttons, um deinen Dank auszudrücken ;)

Kommentare (0)

Für diesen Beitrag wurden Kommentare deaktiviert.