requestAnimationFrame +スタイルのヒントを使用したAngularJSでのスムーズなスクロール

Angularアプリケーション用にスムーズスクロールライブラリを作成する必要がありました。 私がやったことと、なぜそれを始めたのかについて-カットの下で。 途中で、AngularJSのモジュールを設計するためのお気に入りのトリックについて説明します。

導入する代わりに


背景:別のライブラリが必要な理由
標準的な状況が発生しました。Angularアプリケーションが最小限のページをスムーズにスクロールする必要があり、内部完璧主義者はこのためにjQueryをプルすることを禁じていました。 私は「bower search smooth scroll」を行いましたが、Angularには3、4個見ましたが、そのうち2、3年前の最後のコミットについては話していませんでしたが、興味のある人は1人だけでした。 .0(およびこれは既に何かを言っています)そして、ドックから判断すると、彼女は素晴らしく、私のニーズに完全に合っていました(少なくとも条件によってスクロールします)。 すぐに接続して試してみました-うまくいきません...私はドックを数回注意深く読み直し、このように試してみました-うまくいきません...考え直さずに、ドックにエラーがあり、恐ろしいことを願って情報源に登りました。 私が最初に考えたのは、「ITがバージョン2.0.0で何十人もの貢献者とコードのそのようなナンセンスで生き残ることができるのか?」Angularの原則に対する完全な誤解です。 ディレクティブはひどくフレーム化されています:スコープとattrsの誤った理解できない仕事、引数の名前は間違っています。 依存性注入を無視:グローバル関数と変数はどこでも使用されますが、著者自身がそれらのためにサービスを作成しましたが、グローバルウィンドウとドキュメントはどこでもひっくり返ります。 いくつかの場所で、コードはsetTimeoutで不当にラップされています。明らかに、作成者はこれがなぜ必要なのかを完全には理解していないため(バグがあるため)、これにも$タイムアウトがあります。 ディレクティブの属性は接頭辞なしで使用されます(オフセット、継続時間など)。他のライブラリなどと競合する可能性があります。 自分の目で見ることを恐れない人のために-最後にリンク。

まず、コード全体を詳しく調べることなく、最小限のプルリクエストをすばやく作成したため、少なくとも何かがうまくいきました(ディレクティブを完全に書き直しました)が、不快なバグ(アニメーションをぴくぴくさせ、一度トリガーする)になったとき、ファイル全体を見て、状況を修正するために、ほとんどすべてをここで書き直す必要があり、著者がそのようなプルリクエストを受け入れる可能性は低いことに気付きました。さらに、十分な重要な機能がありませんでした。 Angularでのスムーズスクロールの独自バージョン。


長い間、私はこの記事で何に焦点を合わせるかを決めることができませんでした:ライブラリ自体、またはコードのスタイルに関するヒント、またはスムーズなアニメーションとそのデバッグのいずれか...最後に、私はそれを綴る方法を書くことにしました。 そのため、少しずつ散在します。 混乱しないでください。

目標


  1. 指定された条件が満たされたときのページのスムーズなスクロール
  2. 追加の依存関係の欠如(AngularJSを除く)
  3. setTimeoutの代わりにrequestAnimationFrameを使用してスムーズにスクロールします
  4. カスタマイズ機能:スクロール後の画面上部からのインデント、アニメーション期間、イージング、遅延、およびスクロール完了のコールバックも示す
  5. あなたのカンフーにあなたのAngular-モジュール設計のスタイルを見せてください (突然誰かが新しいアイデアを投げるでしょう)
  6. 希釈ホリバー(金曜日までに記事を書き終える時間があれば、最大計画):)

行こう


(function() { //     IIFE,    global scope 'use strict' angular.module('StrongComponents.smoothScroll', []) //   .factory('Utils', Utils) //    .factory('stScroller', stScroller) // ,     .directive('stSmoothScroll', stSmoothScroll) //      }()); 

ここで、Javascript言語の私のお気に入りの機能の1つに既に気づくことができます-これは関数ホイストであり、すべての宣言を可能な限り高く集中させ、以下の実装に集中できるため、すべてのコードを見ずにモジュールの構造をすぐに想像できます(さらに、注意深い読者ここで、私はホリバーの素晴らしいトピックに気付きました)

現在、Utilsには、Angularソースから取得され、srcの未定義の要素がdstの対応する要素を上書きしないように修正された拡張機能が1つだけあります。 githubのAngularカブには長い間Angithの問題がありますが、この全体が修正されるまで待つ時間はありません。

Utilsコード
  /** * Utils functions */ Utils.$inject = [] function Utils() { var service = { extend: extend } return service /** * Extends the destination object `dst` by copying own enumerable properties * from the `src` object(s) to `dst`. Undefined properties are not copyied. * (modified angular version) * * @param {Object} dst Destination object. * @param {...Object} src Source object(s). * @return {Object} Reference to `dst`. */ function extend(dst) { var objs = [].slice.call(arguments, 1), h = dst.$$hashKey for (var i = 0, ii = objs.length; i < ii; ++i) { var obj = objs[i] if (!angular.isObject(obj) && !angular.isFunction(obj)) continue var keys = Object.keys(obj) for (var j = 0, jj = keys.length; j < jj; j++) { var key = keys[j] var src = obj[key] if (!angular.isUndefined(src)) { dst[key] = src } } } if (h) { dst.$$hashKey = h } return dst } } 

この場合も、すべての栄光で巻き上げ機能を果たします。

指令


完全な指令コード
  /** * Smooth scroll directive. */ stSmoothScroll.$inject = ['$document', '$rootScope', 'stScroller'] function stSmoothScroll($document, $rootScope, Scroller) { // subscribe to user scroll events to cancel auto scrollingj angular.forEach(['DOMMouseScroll', 'mousewheel', 'touchmove'], function(ev) { $document.on(ev, function(ev) { $rootScope.$broadcast('stSmoothScroll.documentWheel', angular.element(ev.target)) }) }) var directive = { restrict: 'A', scope: { stScrollIf: '=', stScrollDuration: '=', stScrollOffset: '=', stScrollCancelOnBounds: '=', stScrollDelay: '=', stScrollAfter: '&' }, link: link } return directive /** * Smooth scroll directive link function */ function link(scope, elem, attrs) { var scroller = null // stop scrolling if user scrolls the page himself var offDocumentWheel = $rootScope.$on('stSmoothScroll.documentWheel', function() { if (!!scroller) { scroller.cancel() } }) // unsubscribe scope.$on('$destroy', function() { offDocumentWheel() }) // init scrolling if (attrs.stScrollIf === undefined) { // no trigger specified, start scrolling immediatelly run() } else { // watch trigger and start scrolling, when it becomes `true` scope.$watch('stScrollIf', function(val) { if (!!val) run() }) } /** * Start scrolling, add callback */ function run() { scroller = new Scroller(elem[0], { duration: scope.stScrollDuration, offset: scope.stScrollOffset, easing: attrs.stScrollEasing, cancelOnBounds: scope.stScrollCancelOnBounds, delay: scope.stScrollDelay }) scroller.run().then(function() { // call `after` callback if (typeof scope.stScrollAfter === 'function') scope.stScrollAfter() // forget scroller scroller = null }) } } } 


発表

  /** * Smooth scroll directive. */ stSmoothScroll.$inject = ['$document', '$rootScope', 'stScroller'] function stSmoothScroll($document, $rootScope, Scroller) { ... } 


ディレクティブパラメーター

  function stSmoothScroll(...) { ... var directive = { restrict: 'A', scope: { stScrollIf: '=', stScrollDuration: '=', stScrollOffset: '=', stScrollCancelOnBounds: '=', stScrollDelay: '=', stScrollAfter: '&' }, link: link } return directive ... } 


ユーザー自身が「ハンドルを握った」場合、自動スクロールをキャンセルします

  function stSmoothScroll(...) { angular.forEach(['DOMMouseScroll', 'mousewheel', 'touchmove'], function(ev) { $document.on(ev, function(ev) { $rootScope.$broadcast('stSmoothScroll.documentWheel', angular.element(ev.target)) }) }) var directive = {} return directive .... } 

ここでは、ユーザーがページのスクロールを開始した場合に、さまざまなブラウザーによって生成されるすべての種類のイベントをサブスクライブします。 注意してください :これはlinkではなく、ディレクティブ関数自体で行われ、登録されたすべての要素に対して単一のハンドラーを持ちます。 メッセージは、 $ rootScope。$ Broadcast(...)を介して特定の要素に送信されます。
リンク機能

  var offDocumentWheel = $rootScope.$on('stSmoothScroll.documentWheel', function() { if (!!scroller) { scroller.cancel() } }) scope.$on('$destroy', function() { offDocumentWheel() }) 

ユーザー自身が自動スクロールを中断するためにページのスクロールを開始すると、送信されたメッセージをサブスクライブします。要素が破棄されたときに、サブスクライブの解除を要求しません。
  if (attrs.stScrollIf === undefined) { run() } else { scope.$watch('stScrollIf', function(val) { if (!!val) run() }) } 

トリガーを確認してください。 属性で指定されていない場合はすぐにスクロールし、そうでない場合はtrueになるまで待機しますattrsを参照して、要素の属性を確認します。 (私はtypeof"undefined"についての議論を避けることを望みます。そうではありません)

  function run() { scroller = new Scroller(elem[0], { duration: scope.stScrollDuration, offset: scope.stScrollOffset, easing: attrs.stScrollEasing, cancelOnBounds: scope.stScrollCancelOnBounds, delay: scope.stScrollDelay }) scroller.run().then(function() { if (typeof scope.stScrollAfter === 'function') scope.stScrollAfter() scroller = null }) } 

実際には、スクロールの直接起動。 スコープからサービスにすべてのパラメーターを「見ないで」渡します。 スクロールの完了をサブスクライブし、属性で指定されたコールバックを呼び出し( stScroller.run()はPromiseを返します)、変数をクリアします。

結果は非常に単純なディレクティブです。 スクロールサービスで最も興味深いこと。 さらに進んでいます!

サービス


フルサービスコード
  /** * Smooth scrolling manager */ stScroller.$inject = ['$window', '$document', '$timeout', '$q', 'Utils'] function stScroller($window, $document, $timeout, $q, Utils) { var body = $document.find('body')[0] /** * Smooth scrolling manager constructor * @param {DOM Element} elem Element which window must be scrolled to * @param {Object} opts Scroller options */ function Scroller(elem, opts) { this.opts = Utils.extend({ duration: 500, offset: 100, easing: 'easeInOutCubic', cancelOnBounds: true, delay: 0 }, opts) this.elem = elem this.startTime = null this.framesCount = 0 this.frameRequest = null this.startElemOffset = elem.getBoundingClientRect().top this.endElemOffset = this.opts.offset this.isUpDirection = this.startElemOffset > this.endElemOffset this.curElemOffset = null this.curWindowOffset = null this.donePromise = $q.defer() // this promise is resolved when scrolling is done } Scroller.prototype = { run: run, done: done, animationFrame: animationFrame, requestNextFrame: requestNextFrame, cancel: cancel, isElemReached: isElemReached, isWindowBoundReached: isWindowBoundReached, getEasingRatio: getEasingRatio } return Scroller /** * Run smooth scroll * @return {Promise} A promise which is resolved when scrolling is done */ function run() { $timeout(angular.bind(this, this.requestNextFrame), +this.opts.delay) return this.donePromise.promise } /** * Add scrolling done callback * @param {Function} cb */ function done(cb) { if (typeof cb !== 'function') return this.donePromise.promise.then(cb) } /** * Scrolling animation frame. * Calculate new element and window offsets, scroll window, * request next animation frame, check cancel conditions * @param {DOMHighResTimeStamp or Unix timestamp} time */ function animationFrame(time) { this.requestNextFrame() // set startTime if (this.framesCount++ === 0) { this.startTime = time this.curElemOffset = this.elem.getBoundingClientRect().top this.curWindowOffset = $window.pageYOffset } var timeLapsed = time - this.startTime, perc = timeLapsed / this.opts.duration, newOffset = this.startElemOffset + (this.endElemOffset - this.startElemOffset) * this.getEasingRatio(perc) this.curWindowOffset += this.curElemOffset - newOffset this.curElemOffset = newOffset $window.scrollTo(0, this.curWindowOffset) if (timeLapsed >= this.opts.duration || this.isElemReached() || this.isWindowBoundReached()) { this.cancel() } } /** * Request next animation frame for scrolling */ function requestNextFrame() { this.frameRequest = $window.requestAnimationFrame( angular.bind(this, this.animationFrame)) } /** * Cancel next animation frame, resolve done promise */ function cancel() { cancelAnimationFrame(this.frameRequest) this.donePromise.resolve() } /** * Check if element is reached already * @return {Boolean} */ function isElemReached() { if (this.curElemOffset === null) return false return this.isUpDirection ? this.curElemOffset <= this.endElemOffset : this.curElemOffset >= this.endElemOffset } /** * Check if window bound is reached * @return {Boolean} */ function isWindowBoundReached() { if (!this.opts.cancelOnBounds) { return false } return this.isUpDirection ? body.scrollHeight <= this.curWindowOffset + $window.innerHeight : this.curWindowOffset <= 0 } /** * Return the easing ratio * @param {Number} perc Animation done percentage * @return {Float} Calculated easing ratio */ function getEasingRatio(perc) { switch(this.opts.easing) { case 'easeInQuad': return perc * perc; // accelerating from zero velocity case 'easeOutQuad': return perc * (2 - perc); // decelerating to zero velocity case 'easeInOutQuad': return perc < 0.5 ? 2 * perc * perc : -1 + (4 - 2 * perc) * perc; // acceleration until halfway, then deceleration case 'easeInCubic': return perc * perc * perc; // accelerating from zero velocity case 'easeOutCubic': return (--perc) * perc * perc + 1; // decelerating to zero velocity case 'easeInOutCubic': return perc < 0.5 ? 4 * perc * perc * perc : (perc - 1) * (2 * perc - 2) * (2 * perc - 2) + 1; // acceleration until halfway, then deceleration case 'easeInQuart': return perc * perc * perc * perc; // accelerating from zero velocity case 'easeOutQuart': return 1 - (--perc) * perc * perc * perc; // decelerating to zero velocity case 'easeInOutQuart': return perc < 0.5 ? 8 * perc * perc * perc * perc : 1 - 8 * (--perc) * perc * perc * perc; // acceleration until halfway, then deceleration case 'easeInQuint': return perc * perc * perc * perc * perc; // accelerating from zero velocity case 'easeOutQuint': return 1 + (--perc) * perc * perc * perc * perc; // decelerating to zero velocity case 'easeInOutQuint': return perc < 0.5 ? 16 * perc * perc * perc * perc * perc : 1 + 16 * (--perc) * perc * perc * perc * perc; // acceleration until halfway, then deceleration default: return perc; } } } 


「クラス」の形でサービスを配置することが決定されました(私をbeatるな、すべてを理解しています)。 コンストラクターは、スムーズなスクロールに必要なプロパティの初期値を設定します。 特に注目すべきは、スクロールオプションのデフォルト値の設定です。

  this.opts = Utils.extend({ duration: 500, offset: 100, easing: 'easeInOutCubic', cancelOnBounds: true, delay: 0 }, opts) 

上記で修正された拡張機能により、対応するオプションが要素属性で指定されなかった場合に上書きされないデフォルト値を指定できます。

初期値の設定
  this.elem = elem this.startTime = null this.framesCount = 0 this.frameRequest = null this.startElemOffset = elem.getBoundingClientRect().top this.endElemOffset = this.opts.offset this.isUpDirection = this.startElemOffset > this.endElemOffset this.curElemOffset = null this.curWindowOffset = null this.donePromise = $q.defer() //      resolve,    


方法

  Scroller.prototype = { run: run, //   done: done, //   animationFrame: animationFrame, //    requestNextFrame: requestNextFrame, //    cancel: cancel, //    isElemReached: isElemReached, //     isWindowBoundReached: isWindowBoundReached, //       getEasingRatio: getEasingRatio //   easing- } 

繰り返しますが、関数の巻き上げにより、プロトタイプ全体を簡潔に記述することができます。 コードを読む人は、広告を検索する際にファイル全体をめくることなく、オブジェクトがどのように機能するかをすぐに想像できます。

次に、実装の興味深い瞬間に進みます。

すべてはrunメソッドで始まります。このメソッドでは、アニメーションの最初のフレームが要求され、同時にオプションで指定されたスクロール遅延が処理されます。

  function run() { $timeout(angular.bind(this, this.requestNextFrame), +this.opts.delay) return this.donePromise.promise } .... function requestNextFrame() { this.frameRequest = $window.requestAnimationFrame( angular.bind(this, this.animationFrame)) } function cancel() { cancelAnimationFrame(this.frameRequest) this.donePromise.resolve() } 

このメソッドは、「ユーザー」がアニメーションの最後にサブスクライブする機会を持つように約束を返します(たとえば、スクロールが完了した後に入力にフォーカスを設定するためにフォーカスを設定します。画面外)。

requestNextFrameメソッドは、新しいアニメーションフレームを要求し、その識別子を保存して、 cancelメソッドでキャンセルできるようにします。

cancelメソッドは、次のフレームをキャンセルすることに加えて、コールバックを解決します。

スムーズなスクロールのすべての魔法が発生する場所-animationFrameメソッドに行く時が来ました:

すべてのメソッドコード
  function animationFrame(time) { this.requestNextFrame() // set startTime if (this.framesCount++ === 0) { this.startTime = time this.curElemOffset = this.elem.getBoundingClientRect().top this.curWindowOffset = $window.pageYOffset } var timeLapsed = time - this.startTime, perc = timeLapsed / this.opts.duration, newOffset = this.startElemOffset + (this.endElemOffset - this.startElemOffset) * this.getEasingRatio(perc) this.curWindowOffset += this.curElemOffset - newOffset this.curElemOffset = newOffset $window.scrollTo(0, this.curWindowOffset) if (timeLapsed >= this.opts.duration || this.isElemReached() || this.isWindowBoundReached()) { this.cancel() } } 


メソッドの最初の行はrequestNextFrameを呼び出して、 できるだけ早く次のアニメーションフレームを要求します。 そして、2つのトリックがあります。

  if (this.framesCount++ === 0) { this.startTime = time this.curElemOffset = this.elem.getBoundingClientRect().top this.curWindowOffset = $window.pageYOffset } 


その後、すべてが簡単です:

  var timeLapsed = time - this.startTime, perc = timeLapsed / this.opts.duration, newOffset = this.startElemOffset + (this.endElemOffset - this.startElemOffset) * this.getEasingRatio(perc) this.curWindowOffset += this.curElemOffset - newOffset this.curElemOffset = newOffset $window.scrollTo(0, this.curWindowOffset) if (timeLapsed >= this.opts.duration || this.isElemReached() || this.isWindowBoundReached()) { this.cancel() } 

アニメーションの完了の時間と割合、および要素と画面の新しい位置が計算されます。 計算された位置へのスクロールが呼び出され、アニメーションを終了するための条件がチェックされます。

まとめ


数時間で書かれたモジュールには、冒頭で批判されたような欠点はありません。アニメーションはスムーズで、最低限必要な機能があります。

まだやることがあります:


リクエスト


すべてをそのままgithubに注ぎ、ライセンスと「その他のオープン性」を理解している人に、このビジネスを正しく提案し、支援するよう依頼します。


証明とリンク



ご清聴ありがとうございました!

Source: https://habr.com/ru/post/J270245/


All Articles