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

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

Сегодня я покажу вам как создавать анимированную кнопку «Поделиться» в 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. Мы используем коэффициенты в том числе для того, чтобы было немного дополнительного пространства.

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












Кнопки помещаются в 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.component.html и вставьте в него следующее:



И файл CSS:

/* app/app.component.css */

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

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

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

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


@ViewChildren('shareButton') shareButtonRefs: QueryList;

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

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

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

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

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

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

[...]

[...]

И соответствующий метод в 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 {
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 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.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, представляющие наши кнопки:


[...]

[...]

[...]

Представим 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 {
return ['facebook', 'twitter', 'linkedin', 'github', 'tumblr'];
}
}

И шаблон:







Что даст нам:

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

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

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


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

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

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


[...]

[...]

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

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

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

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

[...]

[...]

// 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 {
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 теперь готов!

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

Источник

Leave a Comment