Создание анимированной кнопки "Поделиться" в NativeScript + Angular

January 2017

Разработка: main view

Се­год­ня я по­ка­жу вам как со­зда­вать ани­ми­ро­ван­ную кноп­ку «По­де­лить­ся» в NativeScript и Angular. При на­жа­тии этой кноп­ки бу­дут по­ка­за­ны ма­лень­кие кноп­ки соц­се­тей по кру­гу от глав­ной.

Ис­ход­ный код при­ме­ра вы мо­же­те уви­деть на Github.

Итак, при­сту­пим!

Уста­нов­ка

Со­зда­дим про­ект, ис­поль­зуя па­ра­метр –ng для со­зда­ния при­ло­же­ния angular:

tns create --ng tns-animated-social-button

В на­шем при­ло­же­нии бу­дет ис­поль­зо­вать­ся пла­гин ng2-fonticon от Nathan Walker для вы­во­да ико­нок на кноп­ках. Уста­но­ви­те его по ин­струк­ции на этой стра­ни­це.
Та­к­же мы ис­поль­зу­ем па­кет lodash. Уста­но­вим его:

npm install --save lodash
npm install --save @types/lodash

Те­перь при­сту­пим к на­ше­му ко­ду.

Со­зда­ние SocialShareButtonComponent

Яд­ро на­ше­го при­ло­же­ния бу­дет опи­са­но в ком­по­нен­те SocialShareButtonComponent. В шаб­лоне бу­дут глав­ная кноп­ка и несколь­ко кно­пок со­ци­аль­ных се­тей.
При на­жа­тии на глав­ную кноп­ку вы­ез­жа­ют ма­лень­кие кноп­ки, а при по­втор­ном на­жа­тии они воз­вра­ща­ют­ся об­рат­но. Для кно­пок мы ис­поль­зу­ем икон­ку «круг» из font awesome. Ико­ноч­ные шриф­ты очень хо­ро­ши тем, что они оди­на­ко­во вы­гля­дят на лю­бом экране и раз­ре­ше­нии. При этом нуж­но пом­нить, что их раз­мер кон­тро­ли­ру­ет­ся па­ра­мет­ром font-size. Для то­го, что­бы сде­лать необ­хо­ди­мый раз­мер ком­по­нен­та, мы долж­ны вы­пол­нить неко­то­рые рас­чё­ты — это из-за то­го, что не все икон­ки в шриф­те име­ют оди­на­ко­вый раз­мер.
На вход мы бу­дем при­ни­мать мас­сив на­име­но­ва­ний для ико­нок. И ис­поль­зо­вать его для со­зда­ния со­от­вет­ству­ю­щих кно­пок. На­име­но­ва­ния возь­мём из спис­ка ико­нок font awesome. Те­перь, зная всё это, да­вай­те со­зда­дим ком­по­нент в но­вой пап­ке social-share-button:

// app/social-share-button/social-share-button.component.html

import {
  Component,
  Input
} from '@angular/core';
import { TNSFontIconModule } from 'nativescript-ng2-fonticon';

@Component({
  selector: 'social-share-button',
  templateUrl: 'social-share-button/social-share-button.component.html',
  styleUrls: ['social-share-button/social-share-button.component.css']
})
export class SocialShareButtonComponent {
  @Input('size') size = 75;
  @Input('shareIcons') shareIcons: string[];

  public get mainIconSize(): number {
    return this.size * 0.45;
  }

  public get shareButtonSize(): number {
    return this.size * 0.55;
  }

  public get shareIconSize(): number {
    return this.shareButtonSize * 0.5;
  }

  public get viewHeight(): number {
    return this.size + this.shareButtonSize * 1.2;
  }

  public get viewWidth(): number {
    return this.size + this.shareButtonSize * 2.2;
  }

  constructor(private fonticon: TNSFontIconModule) {}
}

В пе­ре­мен­ной size мы бу­дем хра­нить расчи­тан­ный раз­мер под раз­ные раз­ре­ше­ния, уста­но­вим по-умол­ча­нию его в 75. Она бу­дет от­ве­чать за па­ра­метр font-size глав­ной кноп­ки. Пе­ре­мен­ная mainIconSize это раз­мер икон­ки в глав­ной кноп­ке. Пе­ре­мен­ная shareButtonSize от­ве­ча­ет за раз­мер дру­гих кно­пок, а shareIconSize, за раз­мер икон­ки в них. Свой­ства viewHeight и viewWidth от­ве­ча­ют за внеш­ние раз­ме­ры все­го пред­став­ле­ния. Нам нуж­но до­ста­точ­но ме­ста для отоб­ра­же­ния глав­ной кноп­ки, а та­к­же всех осталь­ных ма­лых кно­пок. У нас бу­дет мак­си­мум од­на кноп­ка ря­дом с глав­ной, по­это­му вы­со­та ни­ко­гда не пре­вы­сит size + shareButtonSize. Что ка­са­ет­ся ши­ри­ны, у нас бу­дет по од­ной кноп­ке с каж­дой сто­ро­ны, а в ито­ге: size + shareButtonSize x 2. Мы ис­поль­зу­ем ко­эф­фи­ци­ен­ты в том чис­ле для то­го, что­бы бы­ло немно­го до­пол­ни­тель­но­го про­стран­ства.

Со­зда­дим та­кой шаб­лон:

<!-- app/component/social-share-button/social-share-button.component.html -->

<GridLayout rows="auto"
  [style.width]="viewWidth"
  [style.height]="viewHeight">
  <GridLayout #shareButton
    [style.width]="shareButtonSize"
    *ngFor="let shareIcon of shareIcons">
    <Label
      [style.font-size]="shareButtonSize"
      class="fa button"
      [text]="'fa-circle' | fonticon">
    </Label>
    <Label [style.font-size]="shareIconSize"
      class="fa share-icon"
      [text]="'fa-' + shareIcon | fonticon"></Label>
  </GridLayout>
  <GridLayout
    (tap)="onMainButtonTap()"
    [style.width]="size">
    <Label #mainButton
      [style.font-size]="size"
      class="fa button"
      [text]="'fa-circle' | fonticon"></Label>
    <Label [style.font-size]="mainIconSize"
      class="fa share-icon"
      [text]="'fa-share-alt' | fonticon"></Label>
  </GridLayout>
</GridLayout>

Кноп­ки по­ме­ща­ют­ся в GridLayout та­ким об­ра­зом, что­бы икон­ки на­хо­ди­лись по­верх кру­гов. Всё со­дер­жи­мое в свою оче­редь, по­ме­ща­ет­ся в GridLayout, к ко­то­ро­му мы ди­на­ми­че­ски при­ме­ни­ли та­кие свой­ства, как вы­со­та и ши­ри­на.
Для со­зда­ния кно­пок соц­се­тей мы про­хо­дим в цик­ле по мас­си­ву пе­ре­дан­ных ико­нок. Тек­стом икон­ки бу­дет кон­ка­те­на­ция ‘fa-’ и зна­че­ния shareIcon.
За­тем со­зда­дим со­от­вет­ству­ю­щую таб­ли­цу сти­лей:

/* app/social-share-button/social-share-button.component.css */

GridLayout {
  text-align: center;
  vertical-align: center;
}

Label.button {
  color: #000;
}

Label.share-icon {
  color: #FFF;
  vertical-align: center;
}

Здесь мы все­го лишь удо­сто­ве­рим­ся, что всё со­дер­жи­мое GridLayout от­цен­три­ро­ва­но и за­да­дим кое-ка­кие цве­та. Та­к­же сде­ла­ем, что­бы икон­ки бы­ли от­цен­три­ро­ва­ны по вер­ти­ка­ли внут­ри кноп­ки.

Пе­ред тем, как пе­рей­ти к ре­а­ли­за­ции, вы­ве­дем ре­зуль­тат в AppComponent. Сна­ча­ла до­ба­вим Component в спи­сок де­кла­ра­ций AppModule:

// app/app.module.ts

import { NgModule, NO_ERRORS_SCHEMA } from "@angular/core";
import { NativeScriptModule } from "nativescript-angular/platform";
import { SocialShareButtonComponent } from './social-share-button/social-share-button.component';
import { TNSFontIconModule } from 'nativescript-ng2-fonticon';
import { AppComponent } from "./app.component";

@NgModule({
    declarations: [
      AppComponent,
      SocialShareButtonComponent
    ],
    bootstrap: [AppComponent],
    imports: [
      NativeScriptModule,
      TNSFontIconModule.forRoot({
        'fa': 'font-awesome.css'
      })
    ],
    schemas: [NO_ERRORS_SCHEMA]
})
export class AppModule { }

За­тем от­кро­ем AppComponent и немно­го при­че­шем код:

// app/app.component.ts

import { Component } from "@angular/core";

@Component({
  selector: "my-app",
  templateUrl: "app.component.html",
  styleUrls: ['app.component.css']
})
export class AppComponent {
}

Со­здай­те шаб­лон app.​com​pone​nt.​html и вставь­те в него сле­ду­ю­щее:

<!-- app/app.component.html -->

<StackLayout class="container">
  <social-share-button
    [shareIcons]="['facebook', 'twitter', 'github', 'linkedin', 'tumbler']"></social-share-button>
</StackLayout>

И файл CSS:

/* app/app.component.css */

StackLayout.container {
  width: 100%;
  vertical-align: center;
  margin-left: auto;
  margin-right: auto;
}

В ре­зуль­та­те долж­но по­лу­чить­ся та­кое:
Разработка: preview

Ани­ма­ция кно­пок

Сей­час мы по­ра­бо­та­ет над ани­ма­ци­я­ми во­круг глав­ной кноп­ки. Спер­ва со­зда­дим свой­ство @ViewChildren() для по­лу­че­ния GridLayout-ов всех кно­пок:

@ViewChildren('shareButton') shareButtonRefs: QueryList<ElementRef>;

  private get shareButtons(): Array<GridLayout> {
    return this.shareButtonRefs.map(s => s.nativeElement);
  }

Мы хо­тим сде­лать двух­этап­ные ани­ма­ции. Сна­ча­ла кноп­ки соц­се­тей вы­ле­та­ют из-за глав­ной кноп­ки про­стым ли­ней­ным пе­ре­ме­ще­ни­ем. За­тем нам нуж­но сде­лать кое-что по­слож­нее — нам нуж­но, что­бы мел­кие кноп­ки вы­ле­та­ли по кру­гу от глав­ной. Фи­наль­ная по­зи­ция кноп­ки в кру­ге бу­дет за­ви­сеть от по­ло­же­ния дру­гих кно­пок или, дру­ги­ми сло­ва­ми, от её по­зи­ции в мас­си­ве shareButtons.
Для со­зда­ния кру­го­во­го пе­ре­ме­ще­ния вспом­ним, как расчи­ты­ва­ют­ся ко­ор­ди­на­ты x, y от точ­ки по краю окруж­но­сти, в уг­ло­вой функ­ции:

Разработка: Создание анимированной кнопки Поделиться в NativeScript
где x0 и y0 это ко­ор­ди­на­ты на­ча­ла кру­га, r это его ра­ди­ус, а ϴ это угол.

Что­бы мы мог­ли уви­деть кру­го­вую ани­ма­цию, нель­зя про­сто пе­ре­ме­стить точ­ку по кру­гу. Это бы при­ве­ло к пе­ре­се­че­нию окруж­но­сти:
Разработка: Создание анимированной кнопки Поделиться в NativeScript
Вме­сто это­го нуж­но сде­лать несколь­ко по­сле­до­ва­тель­ных пе­ре­ме­ще­ний, ма­лы­ми ша­га­ми (ма­лень­кие ва­ри­а­ции ϴ):
Разработка: Создание анимированной кнопки Поделиться в NativeScript
Пе­ре­ве­дём те­перь это в код.

Ани­ми­ру­ем ма­лые кноп­ки во­круг глав­ной

От­сле­дим тап по глав­ной кноп­ке ме­то­дом onMainButtonTap() на­ше­го Component:

<!-- app/social-share-button/social-share-button.component.html -->

[...]
  <GridLayout
    (tap)="onMainButtonTap()"
    [style.width]="size">
[...]

И со­от­вет­ству­ю­щий ме­тод в Component:

// app/social-share-button/social-share-button.component.ts

[...]
import { Animation } from 'ui/animation';
[...]
  constructor(private fonticon: TNSFontIconModule) {}

  public onMainButtonTap(): void {
    const animationDefinitions = this.shareButtons.map(button => {
      return {
        target: button,
        translate: { x: this.size * 0.8, y: 0 },
        duration: 200
      };
    });
    const animation = new Animation(animationDefinitions);
    animation.play();
  }
}

Пе­ре­ме­ще­ние по оси x бу­дет рав­но зна­че­нию, пря­мо про­пор­ци­о­наль­но­му раз­ме­ру глав­ной кноп­ки. С это­го зна­че­ния и нач­нут­ся все вра­ще­ния. Ес­ли вер­нуть­ся к рас­че­там ко­ор­ди­нат, то это бу­дет ра­ди­у­сом, во­круг ко­то­ро­го мы вра­ща­ем кноп­ки. Зная всё это, со­зда­дим свой­ство-getter для по­лу­че­ния это­го зна­че­ния:

[...]
  private get buttonRotationRadius(): number {
    return this.size * 0.8;
  }

[...]
  public onMainButtonTap(): void {
    const animationDefinitions = this.shareButtons.map(button => {
      return {
        target: button,
        translate: { x: this.buttonRotationRadius, y: 0 },
        duration: 200
      };
    });
    const animation = new Animation(animationDefinitions);
    animation.play();
  }
}

От­сю­да угол ϴ ра­вен ну­лю. Те­перь пе­рей­дём к са­мо­му за­бав­но­му: вра­ще­нию кно­пок.

Кру­го­вые пе­ре­ме­ще­ния кно­пок

Пе­ред тем, как мы про­дол­жим ре­а­ли­за­цию ме­то­да, да­вай­те по­ду­ма­ем о том, что нам нуж­но. Мы го­во­ри­ли, что хо­тим иметь воз­мож­ность пе­ре­ме­щать кноп­ки на неболь­шие уг­лы, или по­ша­го­во. Нам та­к­же нуж­но расчи­тать зна­че­ние мак­си­маль­но­го уг­ла пе­ре­ме­ще­ния каж­дой кноп­ки, в за­ви­си­мо­сти от её по­зи­ции в мас­си­ве. Сде­ла­ем по­ка толь­ко это и со­зда­дим два сле­ду­ю­щих ме­то­да:

[...]
import { range } from 'lodash';
[...]

  private maxAngleFor(index: number): number {
    return index * 45;
  }

  private angleIntervals(maxAngle: number): Array<number> {
    const step = 5;
    return range(0, maxAngle + step, step);
  }
}

Ме­тод maxAngleFor() на вхо­де при­ни­ма­ет index и воз­вра­ща­ет его, умно­жен­ным на 45. Это зна­чит, что каж­дая кноп­ка бу­дет от­де­ле­на чет­вер­тью кру­га — для сим­мет­рии.
Ме­тод angleIntervals() при­ни­ма­ет maxAngle, и воз­вра­ща­ет мас­сив по­сле­до­ва­тель­ных зна­че­ний с ша­гом 5, в пре­де­лах maxAngle. Это бу­дут на­ши ша­ги вра­ще­ния.
Та­к­же мы ре­а­ли­зу­ем ме­тод по­лу­че­ния ко­ор­ди­нат точ­ки, со­от­вет­ству­ю­щей зна­че­нию уг­ла:

[...]
import { Animation, Pair } from 'ui/animation';
[...]
  private buttonCoordinatesFor(angle: number): Pair {
    const x = this.buttonRotationRadius * Math.cos(angle * Math.PI / 180);
    const y = this.buttonRotationRadius * Math.sin(angle * Math.PI / 180);

    return { x: x, y: y };
  }
}

Те­перь важ­ная за­да­ча — сде­лать пе­ре­ме­ще­ния кно­пок с при­вяз­кой к ша­гу по окруж­но­сти. Од­но из ре­ше­ний для это­го — со­здать мас­сив из AnimationDefinition, как мы сде­ла­ли в преды­ду­щем раз­де­ле, и вы­зы­вать ани­ма­ции с фла­гом playSequentially. К со­жа­ле­нию, сде­лай мы так, это при­ве­ло бы к очист­ке пред­став­ле­ния по­сле каж­до­го ша­га ани­ма­ции, что нам аб­со­лют­но не нуж­но. Дру­гое ре­ше­ние — к каж­до­му ша­гу ани­ма­ции при­вя­зы­вать воз­вра­щён­ное зна­че­ние Promise че­рез ме­тод then(). Мы мо­жем сде­лать это с по­мо­щью ме­то­да reduce(), вы­зван­но­го по­сле ме­то­да angleIntervals(). Несколь­ко строк ко­да рас­ска­жут нам боль­ше ты­ся­чи слов:

[...]
    animation.play().then(() => {
      this.shareButtons.forEach((button, index) => {
        const maxAngle = this.maxAngleFor(index);
          this.angleIntervals(maxAngle).reduce((accumulator, currentAngle, index) => {
            return accumulator.then(() => {
              return button.animate({
                translate: this.buttonCoordinatesFor(currentAngle),
                duration: 0.8
              });
            });
          }, Promise.resolve({}));
      });
[...]

Для каж­дой кноп­ки мы по­лу­ча­ем со­от­вет­ству­ю­щее ей зна­че­ние maxAngle. И ис­поль­зу­ем его для рас­чё­та уг­ло­вых ша­гов, вы­зы­вая ме­тод reduce, свя­зы­вая вме­сте все Promise (мы на­ча­ли с ре­зуль­та­та пу­сто­го Promise). Про­дол­жи­тель­ность ани­ма­ции за­ни­ма­ет все­го 0.8 мс, так мы пе­ре­ме­ща­ем кноп­ку на со­от­вет­ству­ю­щие ко­ор­ди­на­ты для те­ку­ще­го уг­ла. На­пом­ню, что на­чи­на­ем мы с уг­ла, рав­но­го 0.

По­сле неболь­шо­го ре­фак­то­рин­га, это пре­вра­ща­ет­ся в:

[...]
  public onMainButtonTap(): void {
    this.translateShareButtonsOutOfMainButton().then(() => {
      this.rotateShareButtonsAroundMainButton();
    });
  }

  private translateShareButtonsOutOfMainButton(): AnimationPromise {
    const animationDefinitions = this.shareButtons.map(button => {
      return {
        target: button,
        translate: { x: this.circularRotationRadius, y: 0 },
        duration: 200
      };
    });
    const animation = new Animation(animationDefinitions);
    return animation.play();
  }

  private rotateShareButtonsAroundMainButton(): void {
    this.shareButtons.forEach((button, index) => {
      this.rotateAroundMainButton(button, index);
    });
  }

  private rotateAroundMainButton(button: GridLayout, index: number): AnimationPromise {
    const maxAngle = this.maxAngleFor(index);
    return this.angleIntervals(maxAngle).reduce(
      this.getStepRotationAccumulatorFor(button),Promise.resolve()
    );
  }

  private getStepRotationAccumulatorFor(button: GridLayout) {
    return (accumulator, currentAngle, index) => {
      return accumulator.then(() => this.doStepRotation(button, currentAngle));
    }
  }

  private doStepRotation(button: GridLayout, angle: number): AnimationPromise {
    return button.animate({
      translate: this.buttonCoordinatesFor(angle),
      duration: 0.8
    });
  }
}

Воз­врат кно­пок на ме­сто

Ко­гда кноп­ки по­ка­жут­ся, нам по­на­до­бит­ся спо­соб вер­нуть их на­зад, от­ку­да они вы­шли. Что­бы это сде­лать, нам по­на­до­бит­ся флаг shareButtonDisplayed, по­ка­зы­ва­ю­щий ви­ди­мость кно­пок:

[...]
@Component({
  selector: 'social-share-button',
  templateUrl: 'social-share-button/social-share-button.component.html',
  styleUrls: ['social-share-button/social-share-button.component.css']
})
export class SocialShareButtonComponent {

  private shareButtonDisplayed = false;
[...]

Ани­ма­ция об­рат­но­го воз­вра­та кно­пок бу­дет очень по­хо­жа на translateShareButtonsOutOfMainButton(), по­это­му мы возь­мём со­дер­жи­мое ме­то­да, что­бы сде­лать его бо­лее уни­фи­ци­ро­ван­ным:

[...]
  private translateShareButtonsOutOfMainButton(): AnimationPromise {
    return this.translateShareButtonsTo({
      x: this.circularRotationRadius,
      y: 0
    })
  }

  private translateShareButtonsTo(coordinates: Pair): AnimationPromise {
    const animationDefinitions = this.shareButtons.map(button => {
      return {
        target: button,
        translate: coordinates,
        duration: 200
      };
    });
    const animation = new Animation(animationDefinitions);
    return animation.play();
  }
[...]

Что поз­во­лит нам на­пи­сать:

[...]
  private translateShareButtonsBackInMainButton(): AnimationPromise {
    return this.translateShareButtonsTo({ x: 0, y: 0 });
  }
[...]

И те­перь мы мо­жем пе­ре­пи­сать onMainButtonTap():

[...]
  public onMainButtonTap(): void {
    if (!this.shareButtonDisplayed) {
      this.translateShareButtonsOutOfMainButton().then(() => {
        this.rotateShareButtonsAroundMainButton();
      });
    }
    else {
      this.translateShareButtonsBackInMainButton();
    }
    this.shareButtonDisplayed = !this.shareButtonDisplayed;
  }
[...]

Про­бле­ма те­ку­щей ре­а­ли­за­ции в том, что поль­зо­ва­тель мо­жет по­ло­мать на­шу ани­ма­цию. Что­бы это­го из­бе­жать, мы вве­дём пе­ре­мен­ную-пе­ре­чис­ле­ние State, по­ка­зы­ва­ю­щую со­сто­я­ние Component: ожи­да­ние, про­иг­ры­ва­ние или оста­нов­лен. Пе­ред этим необ­хо­ди­мо пе­ре­де­лать ме­тод rotateShareButtonsAroundMainButton() для воз­вра­та Promise. В этом ме­то­де мы хо­тим воз­вра­щать ре­зуль­тат Promise-ов всех ани­ма­ций, по­это­му мы долж­ны пой­мать мо­мент окон­ча­ния всей ани­ма­ции (оста­нов­лен). Из­ме­ним ме­тод сле­ду­ю­щим об­ра­зом:

[...]
  private rotateShareButtonsAroundMainButton(): AnimationPromise {
    const animationPromises = this.shareButtons.map((button, index) => {
      return this.rotateAroundMainButton(button, index);
    });
    return <AnimationPromise>Promise.all(animationPromises);
  }
[...]

Из­ме­ним флаг по со­сто­я­нию ани­ма­ции:

[...]
enum AnimationState {
  idle,
  animating,
  settled
}

@Component({
  selector: 'social-share-button',
  templateUrl: 'social-share-button/social-share-button.component.html',
  styleUrls: ['social-share-button/social-share-button.component.css']
})
export class SocialShareButtonComponent {

  private animationState = AnimationState.idle;
[...]

И фи­наль­ная ре­а­ли­за­ция:

[...]
  public onMainButtonTap(): void {
    if (this.animationState === AnimationState.idle) {
      this.translateShareButtonsOutOfMainButton().then(() => {
        this.animationState = AnimationState.animating;
        return this.rotateShareButtonsAroundMainButton();
      }).then(() => {
       this.animationState = AnimationState.settled;
      });
    }
    if (this.animationState === AnimationState.settled) {
      this.translateShareButtonsBackInMainButton().then(() => {
        this.animationState = AnimationState.idle;
      });
    }
  }
[...]

К это­му мо­мен­ту вы уже долж­ны убе­дить­ся в кра­со­те Promise-ов в JavaScript.

Де­ла­ем кноп­ки на­стра­и­ва­е­мы­ми

Сей­час на­ши чер­но-бе­лые кноп­ки вы­гля­дят очень скуч­но. Сде­ла­ем их на­стра­и­ва­е­мы­ми. До­ба­вим па­ру Input-ов (с неко­то­ры­ми зна­че­ни­я­ми по-умол­ча­нию):

[...]
  @Input('buttonColor') buttonColor = '#CC0000';
  @Input('iconColor') iconColor = '#FFFFFF';
[...]

И при­вя­жем к шаб­ло­ну:

<!-- app/social-share-button/social-share-button.component.html -->

<GridLayout rows="auto"
  [style.width]="viewWidth"
  [style.height]="viewHeight">
  <GridLayout #shareButton
    [style.width]="shareButtonSize"
    *ngFor="let shareIcon of shareIcons">
    <Label
      [style.color]="buttonColor"
      [style.font-size]="shareButtonSize"
      class="fa button"
      [text]="'fa-circle' | fonticon">
    </Label>
    <Label [style.font-size]="shareIconSize"
      [style.color]="iconColor"
      class="fa share-icon"
      [text]="'fa-' + shareIcon | fonticon"></Label>
  </GridLayout>
  <GridLayout
    (tap)="onMainButtonTap()"
    [style.width]="size">
    <Label
      [style.color]="buttonColor"
      [style.font-size]="size"
      class="fa button"
      [text]="'fa-circle' | fonticon"></Label>
    <Label [style.font-size]="mainIconSize"
      [style.color]="iconColor"
      class="fa share-icon"
      [text]="'fa-share-alt' | fonticon"></Label>
  </GridLayout>
</GridLayout>

А та­к­же мож­но немно­го под­со­кра­тить таб­ли­цу сти­лей:

/* app/social-share-button/social-share-button.component.css */

GridLayout {
  text-align: center;
  vertical-align: center;
}

Label.share-icon {
  vertical-align: center;
}

До­ба­вим эф­фект те­ни с по­мо­щью на­тив­но­го ко­да

Немно­го улуч­шим стиль кноп­ки, до­ба­вив к ней тень. NativeScript по­ка не под­дер­жи­ва­ет по­каз те­ни в пред­став­ле­нии, по­это­му мы сде­ла­ем это на на­тив­ном ко­де, с по­мо­щью Directive, ко­то­рая мо­жет быть ре­а­ли­зо­ва­на и для iOS и для Android.
Со­зда­дим но­вую пап­ку спе­ци­аль­но для ко­да на­шей Directive, на­зо­вём её label-shadow. Те­перь со­зда­дим аб­стракт­ную ба­зо­вую ди­рек­ти­ву, ко­то­рая бу­дет уна­сле­до­ва­на каж­дой плат­фор­мой:

// app/label-shadow/label-shadow-base.directive.ts

import { Directive, ElementRef } from '@angular/core';
import { Label } from 'ui/label';
import { Observable } from 'data/observable';
import { Color } from 'color';

@Directive({
  selector: '[shadow]'
})

export abstract class LabelShadowBaseDirective {

  private get label(): Label {
    return this.el.nativeElement;
  }

  protected get shadowColor(): Color {
    return new Color('#888888');
  }

  protected get shadowOffset(): number {
    return 5.0;
  }

  constructor(protected el: ElementRef) {
    this.label.on(Observable.propertyChangeEvent, () => {
      if (this.label.text !== undefined) {
        this.displayShadowOn(this.label);
      }
    });
  }

  protected abstract displayShadowOn(label: Label);
}

Нам нуж­но по­до­ждать, по­ка Label с пла­ги­ном FontIcon на­стро­ит­ся, по­это­му до­ба­вим хук — пе­ре­хват­чик со­бы­тия. По его го­тов­но­сти мы при­ме­ним аб­стракт­ный ме­тод displayShadowOn().

Пе­ред тем, как взять­ся за ре­а­ли­за­цию, со­зда­дим опи­са­ние ти­пов, ко­то­рое по­ка­жет TypeScript, что ди­рек­ти­ва здесь бу­дет во вре­мя ком­пи­ля­ции:

// app/label-shadow/label-shadow.directive.ts

import { Label } from 'ui/label';
import { LabelShadowBaseDirective } from './label-shadow-base.directive';

export declare class LabelShadowDirective extends LabelShadowBaseDirective {
  constructor(label: Label);
  protected displayShadowOn(label: Label);
}

Со­зда­дим ре­а­ли­за­цию под Android:

// app/label/label-shadow.directive.android.ts

import { Directive, ElementRef } from '@angular/core';
import { Label } from 'ui/label';
import { LabelShadowBaseDirective } from './label-shadow-base.directive';
import { Color } from 'color';

@Directive({
  selector: '[shadow]'
})
export class LabelShadowDirective extends LabelShadowBaseDirective {
  constructor(protected el: ElementRef) {
    super(el);
  }

  protected displayShadowOn(label: Label) {
    const nativeView = label.android;
    nativeView.setShadowLayer(
      10.0,
      this.shadowOffset,
      this.shadowOffset,
      this.shadowColor.android
    );
  }
}

И для iOS:

// app/label-shadow/label-shadow.directive.ios.ts

import { Directive, ElementRef } from '@angular/core';
import { Label } from 'ui/label';
import { Observable } from 'data/observable';
import { LabelShadowBaseDirective } from './label-shadow-base.directive';
import { Color } from 'color';

declare const CGSizeMake: any;

@Directive({
  selector: '[shadow]'
})
export abstract class LabelShadowDirective extends LabelShadowBaseDirective {

  constructor(protected el: ElementRef) {
    super(el);
  }

  protected displayShadowOn(label: Label) {
    const nativeView = label.ios;
    nativeView.layer.shadowColor = this.shadowColor.ios.CGColor;
    nativeView.layer.shadowOffset = CGSizeMake(this.shadowOffset, this.shadowOffset);
    nativeView.layer.shadowOpacity = 1.0;
    nativeView.layer.shadowRadius = 2.0;
  }
}

За­тем до­ба­вим Directive в де­кла­ра­ции AppModule:

// app/app.module.ts

import { NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { NativeScriptModule } from 'nativescript-angular/platform';
import { SocialShareButtonComponent } from './social-share-button/social-share-button.component';
import { TNSFontIconModule } from 'nativescript-ng2-fonticon';
import { AppComponent } from './app.component';
import { LabelShadowDirective } from './label-shadow/label-shadow.directive';

@NgModule({
    declarations: [
      AppComponent,
      SocialShareButtonComponent,
      LabelShadowDirective
    ],
    bootstrap: [AppComponent],
    imports: [
      NativeScriptModule,
      TNSFontIconModule.forRoot({
        'fa': 'font-awesome.css'
      })
    ],
    schemas: [NO_ERRORS_SCHEMA]
})
export class AppModule { }

Те­перь мы мо­жем до­ба­вить ди­рек­ти­ву в Label-ы FontIcon, пред­став­ля­ю­щие на­ши кноп­ки:

<!-- app/social-share-button/social-share-button.component.html -->

[...]
    <Label
      shadow
      [style.color]="buttonColor"
      [style.font-size]="shareButtonSize"
      class="fa button"
      [text]="'fa-circle' | fonticon">
    </Label>
[...]
    <Label
      shadow
      [style.color]="buttonColor"
      [style.font-size]="size"
      class="fa button"
      [text]="'fa-circle' | fonticon"></Label>
[...]

Пред­ста­вим Component в раз­ных раз­ме­рах и цве­тах. От­ре­дак­ти­ру­ем AppComponent:

// app/app.component.ts

import { Component } from "@angular/core";

@Component({
  selector: "my-app",
  templateUrl: "app.component.html",
  styleUrls: ['app.component.css']
})
export class AppComponent {
  public get shareIcons(): Array<string> {
    return ['facebook', 'twitter', 'linkedin', 'github', 'tumblr'];
  }
}

И шаб­лон:

<!-- app/app.component.html -->

<StackLayout class="container">
  <social-share-button
    [shareIcons]="shareIcons"
    [size]="100">
  <social-share-button
    [buttonColor]="'#581845'"
    [shareIcons]="shareIcons"
    [size]="80">
  <social-share-button
    [buttonColor]="'#FFC300'"
    [iconColor]="'#C70039'"
    [shareIcons]="shareIcons"
    [size]="60">
  <social-share-button
    [buttonColor]="'#99D5FF'"
    [iconColor]="'#000000'"
    [shareIcons]="shareIcons"
    [size]="40">
</StackLayout>

Что даст нам:

Разработка: Создание анимированной кнопки Поделиться в NativeScript

Ре­зуль­тат на­жа­той кноп­ки «По­де­лить­ся»

Кноп­ки уже вы­гля­дят хо­ро­шо, но они бес­по­лез­ны, по­то­му что ни­че­го не де­ла­ют. Вве­дём EventEmitter Output, ко­то­рый бу­дет по­ка­зы­вать имя икон­ки, ко­гда со­от­вет­ству­ю­щая кноп­ка бу­дет на­жа­та:

// app/social-share-button.component.ts

[...]
  @Output('shareButtonTap') shareButtonTap = new EventEmitter<string>();
[...]

За­тем при­вя­жем хук к (tap) GridLayout-а кноп­ки на ме­тод onShareButton(), пе­ре­да­вая ему на­зва­ние икон­ки:

<!-- app/social-share-button/social-share-button.component.html -->

[...]
  <GridLayout #shareButton
    [style.width]="shareButtonSize"
    *ngFor="let shareIcon of shareIcons"
    (tap)="onShareButtonTap(shareIcon)">
[...]

Со­зда­дим со­от­вет­ству­ю­щий ме­тод, по­ка­зы­ва­ю­щий имя знач­ка, пе­ре­дав ему па­ра­мет­ром икон­ку:

// app/social-share-button/social-share-button.component.ts

[...]
  public onShareButtonTap(icon: string): void {
    this.shareButtonTap.emit(icon);
  }
[...]

Это поз­во­ля­ет под­пи­сать­ся на со­бы­тие в AppComponent:

<!-- app/app.component.html -->

[...]
  <social-share-button
    [shareIcons]="shareIcons"
    [size]="100"
    (shareButtonTap)="onShareButtonTap($event)"></social-share-button>
[...]
// app/app.component.ts

import { Component } from "@angular/core";
import * as dialogs from 'ui/dialogs';

@Component({
  selector: "my-app",
  templateUrl: "app.component.html",
  styleUrls: ['app.component.css']
})
export class AppComponent {
  public get shareIcons(): Array<string> {
    return ['facebook', 'twitter', 'linkedin', 'github', 'tumblr'];
  }

  public onShareButtonTap(event: string): void {
    dialogs.alert(`share on: ${event}`);
  }
}

До­ба­вим немно­го про­ве­рок

В по­след­нем ша­ге до­ба­вим про­вер­ки, для то­го, что­бы предот­вра­тить некор­рект­ное ис­поль­зо­ва­ние Component-а.

Окро­ем ещё раз SocialShareButton, и сде­ла­ем так, что­бы он ре­а­ли­зо­вы­вал ин­тер­фейс OnInit:

// app/social-share-button/social-share-button.component.ts

import {
[...]
  OnInit
} from '@angular/core';

[...]
export class SocialShareButtonComponent implements OnInit {
[...]

за­тем ре­а­ли­зу­ем пе­ре­хват ngOnInit() с про­вер­ка­ми:

// app/social-share-button.component.ts

[...]
  public ngOnInit() {
    if (!this.shareIcons || this.shareIcons.length === 0) {
      throw new Error('you need to specify at least 1 icon');
    }
    if (this.shareIcons.length > 5) {
      throw new Error('the list of icons cannot contain more than 5 elements');
    }
  }
[...]

Наш Component те­перь го­тов!

video

Ес­ли вам по­нра­вил­ся этот ма­те­ри­ал, не за­будь­те по­де­лить­ся им с кол­ле­га­ми!

Ис­точ­ник

comments powered by Disqus