Привет! Продолжаю выкладывать перевод статьи, которую я использовал как основу для реализации социального функционала в нашем проекте Dom24x7, где люди могут общаться друг с другом, решать возникающие бытовые проблемы, а также взаимодействовать с УК/ТСЖ. Первую часть статьи можно прочитать тут, а вторую смотрите тут.
Итак...
Создаем иконку угасания
Пришло время для еще одного глобального компонента. Это довольно простой виджет, который отвечает за угасание иконки, или насыщение ее цвета, путем нажатия на нее.
Создайте components/app_widgets/tap_fade_icon.dart
и добавьте следующее:
import 'package:flutter/material.dart';
/// {@template tap_fade_icon}
/// A tappable icon that fades colors when tapped and held.
/// {@endtemplate}
class TapFadeIcon extends StatefulWidget {
/// {@macro tap_fade_icon}
const TapFadeIcon({
Key? key,
required this.onTap,
required this.icon,
required this.iconColor,
this.size = 22,
}) : super(key: key);
/// Callback to handle tap.
final VoidCallback onTap;
/// Color of the icon.
final Color iconColor;
/// Type of icon.
final IconData icon;
/// Icon size.
final double size;
@override
_TapFadeIconState createState() => _TapFadeIconState();
}
class _TapFadeIconState extends State<TapFadeIcon> {
late Color color = widget.iconColor;
void handleTapDown(TapDownDetails _) {
setState(() {
color = widget.iconColor.withOpacity(0.7);
});
}
void handleTapUp(TapUpDetails _) {
setState(() {
color = widget.iconColor;
});
widget.onTap(); // Execute callback.
}
@override
void didUpdateWidget(covariant TapFadeIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.iconColor != widget.iconColor) {
color = widget.iconColor;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: handleTapDown,
onTapUp: handleTapUp,
child: Icon(
widget.icon,
color: color,
size: widget.size,
),
);
}
}
Вы можете поподробнее остановиться на изучении этого виджета самостоятельно.
Добавьте этот класс в нужный баррель-файл. Откройте components/app_widgets/app_widgets.dart
и добавьте нижележащий код:
export 'avatars.dart';
export 'tap_fade_icon.dart'; // ADD THIS
Создаем пользовательскую ленту
Как уже было упомянуто ранее, наш инстаграм клон можно теоретически разбить на две части: на пользовательскую (user) и хронологическую (timeline) ленты. Каждый пользователь имеет свою уникальную пользовательскую ленту, где отображаются все посты, сделанные им; в то время как хронологическая лента это комбинация всех лент пользователей, на которых вы подписаны.
В этой секции, вы создадите функционал, позволяющий продвигать действия (в данном случае - посты пользователя) прямиком в пользовательскую ленту.
Для того чтобы сделать наш клон Instagram максимально приближенным по ощущениям во время использования и интерактивности к настоящему приложению, мы в первую очередь должны закончить с кодом для пользовательской ленты. Для этого нам нужно:
создать экран, где можно добавлять новые посты;
добавить PictureViewer (hero анимации);
обновить AppBar (верхнюю панель приложения) так, чтобы мы могли добавить больше чем одну фотографию в профиль;
внедрить возможность подписываться и отписываться от лент других пользователей.
Разобравшись со всем этим, далее, мы сможем перейти к созданию хронологической ленты.
Создаем экран «Новая публикация»
Это тот самый экран, где вы добавляете новое фото в свой профиль вместе с подписью к нему.
Создайте файл components/new_post/new_post_screen.dart
и добавьте в него:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';
import 'package:transparent_image/transparent_image.dart';
import '../../app/app.dart';
import '../app_widgets/app_widgets.dart';
/// Screen to choose photos and add a new feed post.
class NewPostScreen extends StatefulWidget {
/// Create a [NewPostScreen].
const NewPostScreen({Key? key}) : super(key: key);
/// Material route to this screen.
static Route get route =>
MaterialPageRoute(builder: (_) => const NewPostScreen());
@override
_NewPostScreenState createState() => _NewPostScreenState();
}
class _NewPostScreenState extends State<NewPostScreen> {
static const double maxImageHeight = 1000;
static const double maxImageWidth = 800;
final _formKey = GlobalKey<FormState>();
final _text = TextEditingController();
XFile? _pickedFile;
bool loading = false;
final picker = ImagePicker();
Future<void> _pickFile() async {
_pickedFile = await picker.pickImage(
source: ImageSource.gallery,
maxHeight: maxImageHeight,
maxWidth: maxImageWidth,
imageQuality: 70,
);
setState(() {});
}
Future<void> _postImage() async {
if (_pickedFile == null) {
context.removeAndShowSnackbar('Please select an image first');
return;
}
if (!_formKey.currentState!.validate()) {
context.removeAndShowSnackbar('Please enter a caption');
return;
}
_setLoading(true);
final client = context.appState.client;
var decodedImage =
await decodeImageFromList(await _pickedFile!.readAsBytes());
final imageUrl =
await client.images.upload(AttachmentFile(path: _pickedFile!.path));
if (imageUrl != null) {
final _resizedUrl = await client.images.getResized(
imageUrl,
const Resize(300, 300),
);
if (_resizedUrl != null && client.currentUser != null) {
await FeedProvider.of(context).bloc.onAddActivity(
feedGroup: 'user',
verb: 'post',
object: 'image',
data: {
'description': _text.text,
'image_url': imageUrl,
'resized_image_url': _resizedUrl,
'image_width': decodedImage.width,
'image_height': decodedImage.height,
'aspect_ratio': decodedImage.width / decodedImage.height
},
);
}
}
_setLoading(false, shouldCallSetState: false);
context.removeAndShowSnackbar('Post created!');
Navigator.of(context).pop();
}
void _setLoading(bool state, {bool shouldCallSetState = true}) {
if (loading != state) {
loading = state;
if (shouldCallSetState) {
setState(() {});
}
}
}
@override
void dispose() {
_text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: TapFadeIcon(
onTap: () => Navigator.pop(context),
icon: Icons.close,
iconColor: Theme.of(context).appBarTheme.iconTheme!.color!,
),
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: GestureDetector(
onTap: _postImage,
child: const Text('Share', style: AppTextStyle.textStyleAction),
),
),
)
],
),
body: loading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Uploading...')
],
),
)
: ListView(
children: [
InkWell(
onTap: _pickFile,
child: SizedBox(
height: 400,
child: (_pickedFile != null)
? FadeInImage(
fit: BoxFit.contain,
placeholder: MemoryImage(kTransparentImage),
image: Image.file(File(_pickedFile!.path)).image,
)
: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomLeft,
end: Alignment.topRight,
colors: [
AppColors.bottomGradient,
AppColors.topGradient
]),
),
height: 300,
child: const Center(
child: Text(
'Tap to select an image',
style: TextStyle(
color: AppColors.light,
fontSize: 18,
shadows: <Shadow>[
Shadow(
offset: Offset(2.0, 1.0),
blurRadius: 3.0,
color: Colors.black54,
),
Shadow(
offset: Offset(1.0, 1.5),
blurRadius: 5.0,
color: Colors.black54,
),
],
),
),
),
),
),
),
const SizedBox(
height: 22,
),
Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextFormField(
controller: _text,
decoration: const InputDecoration(
hintText: 'Write a caption',
border: InputBorder.none,
),
validator: (text) {
if (text == null || text.isEmpty) {
return 'Caption is empty';
}
return null;
},
),
),
),
],
),
);
}
}
Давайте разберем данный код в деталях:
по тому же принципу как и ранее, здесь, мы используем пакет image_picker для того, чтобы выбрать изображение. Как только оно выбрано, мы задаем значение _pickedFile для локальной переменной;
в этой секции мы также создаем TextFormField, который требует от нас ввода описания для для нашего поста;
как только изображение было выбрано и описание введено, пользователь может нажать кнопку Share, чтобы выложить свой пост. Если эти условия не были соблюдено, то на экране появится ошибка.
Кнопка Share вызывает метод _postImage
, который выполняет следующее:
Устанавливает статус true для состояния загрузки изображения;
Декодирует изображение так, чтобы оно приняло указанный нами размер;
Загружает изображение на Stream CDN;
При помощи метода
getResized
создает уменьшенную версию изображения;Использует
FeedProvider.of(context).bloc
, чтобы возвратить FeedBloc и создает новый пост при помощиonAddActivity
.
В случае с нашим инстаграм клоном, создание одного поста требует наличия данных об: исполнителе (actor), действии (verb) и объекте (object). Исполнитель - это субъект, выполняющий действие (пользователь). Действие в нашем случае - публикует. Объект или то над чем совершается действие - изображение.
Проще говоря, текущий пользователь публикует изображение (исполнитель, действие, объект) в пользовательскую ленту. Затем, мы указываем немного дополнительной информации, включающей:
описание изображения;
соотношение сторон (будет разобрано позже);
URL адрес самого изображения и его уменьшенной версии.
Вот и все. Статья была большой и, надеюсь, полезной.
Больше информации про то, как добавлять действия в ленту вы можете найти в соответствующей документации. Действия и ленты бывают довольно сложными. Но благодаря этой сложности мы можем работать с более продвинутыми функциями. Однако пока что, описанного выше будет вполне достаточно.
Ваш экран сейчас должен выглядеть следующим образом:
Создайте баррель-файл components/new_post/new_post.dart
и добавьте:
export 'new_post_screen.dart';
Добавляем навигацию и отображение постов (действий) в профиле
Откройте файл components/profile/profile_page.dart
.
Измените виджет _NoPostsMessage
, чтобы иметь возможность переходить к NewPostScreen (экрану «Новая публикация»).
...
import '../new_post/new_post.dart';
...
class _NoPostsMessage extends StatelessWidget {
const _NoPostsMessage({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('This is too empty'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(NewPostScreen.route); // ADD THIS
},
child: const Text('Add a post'),
)
],
);
}
}
...
В ProfilePage приведите виджет feedBuilder к следующему виду:
import 'package:cached_network_image/cached_network_image.dart';
...
feedBuilder: (context, activities) {
return RefreshIndicator(
onRefresh: () async {
await FeedProvider.of(context)
.bloc
.currentUser!
.get(withFollowCounts: true);
return FeedProvider.of(context)
.bloc
.queryEnrichedActivities(feedGroup: 'user');
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _ProfileHeader(
numberOfPosts: activities.length,
),
),
const SliverToBoxAdapter(
child: _EditProfileButton(),
),
const SliverToBoxAdapter(
child: SizedBox(height: 24),
),
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 1,
mainAxisSpacing: 1,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final activity = activities[index];
final url =
activity.extraData!['resized_image_url'] as String;
return CachedNetworkImage(
key: ValueKey('image-${activity.id}'),
width: 200,
height: 200,
fit: BoxFit.cover,
imageUrl: url,
);
},
childCount: activities.length,
),
)
],
),
);
},
...
Теперь, когда в вашем приложении можно добавлять действия (посты), нам нужно решить проблему с тем, как отобразить эти посты в нашей ленте. Билдер, приведенный выше, выполняет следующее:
Создает CustomScrollView;
Отображает _ProfileHeader, показывающий в поле Публикации (вверху профиля) цифру соответствующую вашему реальному количеству постов в профиле;
Отображает _EditProfileButton (кнопку «Редактировать профиль»);
Обертывает список в RefreshIndicator, позволяя пользователям путем свайпа вниз обновлять страницу профиля с постами через получение последних данных с сервера. Достигается это путем вызова методов
currentUser.get
иbloc.queryEnrichedActivities
. ДалееqueryEnrichedActivities
обновит состояние FeedBloc для данной группы лент.
Готово! Теперь вы должны иметь возможность заходить в приложение под одним из наших пользователей и загружать фотографии в свою пользовательскую ленту (профиль). В видео ниже приведен пример того, как это выглядит:
Добавляем анимации переходов при просмотре изображений (PictureViewer)
Перед тем как мы наконец перейдем к хронологической ленте, давайте узнаем немного про переходы.
Вы наверняка замечали то, как плавно открывается изображение при нажатии на него из решетки изображений в реальном Instagram. Для того, чтобы воссоздать это поведение, мы создадим просмотрщик изображений включающий в себя функцию hero анимаций (переход изображений от низкого разрешения к высокому). Так мы получим плавную анимацию при открытии изображения на полный экран, предварительно нажав на него из решетки изображений. Также мы добавим функции увеличения изображения и возможность перемещаться по нему, когда мы приближаем его.
Чтобы получить это поведение, мы:
Создаем CustomRectTween для нашей собственной hero анимации;
Создаем PageRoute, который, в свою очередь, создает FadeTransition;
Обновляем UI в файле
components/profile/profile_page.dart
, чтобы выполнить переход;Находим применение CachedNetworkImage, чтобы реализовать переход от кэшированного изображения низкого разрешения к его версии с более высоким разрешением;
Используем виджет InteractiveViewer для внедрения возможности приближать изображение двумя пальцами и перемещаться по нему когда оно находится в таком состоянии.
Навигация
Первым делом, вам нужно будет создать несколько классов помощников для реализации переходов.
Создайте файл app/navigation/custom_rect_tween.dart
и добавьте следующее:
import 'dart:ui';
import 'package:flutter/widgets.dart';
/// {@template custom_rect_tween}
/// Linear RectTween with a [Curves.easeOut] curve.
///
/// Less dramatic than the regular [RectTween] used in [Hero] animations.
/// {@endtemplate}
class CustomRectTween extends RectTween {
/// {@macro custom_rect_tween}
CustomRectTween({
required Rect? begin,
required Rect? end,
}) : super(begin: begin, end: end);
@override
Rect? lerp(double t) {
final elasticCurveValue = Curves.easeOut.transform(t);
if (begin == null || end == null) return null;
return Rect.fromLTRB(
lerpDouble(begin!.left, end!.left, elasticCurveValue)!,
lerpDouble(begin!.top, end!.top, elasticCurveValue)!,
lerpDouble(begin!.right, end!.right, elasticCurveValue)!,
lerpDouble(begin!.bottom, end!.bottom, elasticCurveValue)!,
);
}
}
Этот класс расширяет RectTween и перезаписывает метод lerp. Мы вернемся к этому позже, когда будем заменять стандартную hero анимацию. Термин lerp описывает интерполяцию между начальным и конечным значением переменной на промежутке времени (t).
Далее, создайте файл app/navigation/hero_dialog_route.dart
и добавьте следующее:
import 'package:flutter/material.dart';
/// {@template hero_dialog_route}
/// Custom [PageRoute] that creates an overlay dialog (popup effect).
///
/// Best used with a [Hero] animation.
/// {@endtemplate}
class HeroDialogRoute<T> extends PageRoute<T> {
/// {@macro hero_dialog_route}
HeroDialogRoute({
required WidgetBuilder builder,
RouteSettings? settings,
bool fullscreenDialog = false,
}) : _builder = builder,
super(settings: settings, fullscreenDialog: fullscreenDialog);
final WidgetBuilder _builder;
@override
bool get opaque => false;
@override
bool get barrierDismissible => true;
@override
Duration get transitionDuration => const Duration(milliseconds: 300);
@override
bool get maintainState => true;
@override
Color get barrierColor => Colors.black54;
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return FadeTransition(opacity: animation, child: child);
}
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return _builder(context);
}
@override
String get barrierLabel => 'Hero Dialog Open';
}
В этой части кода мы пользуемся классом PageRoute, который помогает нам реализовать плавный переход (FadeTransition) c задним фоном черного цвета. Есть множество других способов как вы можете сделать такой же переход. Самостоятельно, вы можете подольше задержаться на этом классе, попробовать поиграться с ним и посмотреть какие возможности он предоставляет. Ранее в статье мы уже использовали PageRouteBuilder, который вы можете использовать в качестве альтернативного способа для создания такого перехода. Однако, благодаря расширению этого класса у вас есть больше элементов контроля.
Создайте баррель-файл app/navigation/navigation.dart
и добавьте в него:
export 'custom_rect_tween.dart';
export 'hero_dialog_route.dart';
Обновите app/app.dart
следующим образом:
export 'state/state.dart';
export 'theme.dart';
export 'stream_agram.dart';
export 'utils.dart';
export 'navigation/navigation.dart'; // ADD THIS
UI
Откройте файл components/profile/profile_page.dart
и добавьте в него всё приведенное ниже:
...
class _PictureViewer extends StatelessWidget {
const _PictureViewer({
Key? key,
required this.activity,
}) : super(key: key);
final EnrichedActivity activity;
@override
Widget build(BuildContext context) {
final resizedUrl = activity.extraData!['resized_image_url'] as String?;
final fullSizeUrl = activity.extraData!['image_url'] as String;
final aspectRatio = activity.extraData!['aspect_ratio'] as double?;
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
),
extendBodyBehindAppBar: true,
body: InteractiveViewer(
child: Center(
child: Hero(
tag: 'hero-image-${activity.id}',
createRectTween: (begin, end) {
return CustomRectTween(begin: begin, end: end);
},
child: AspectRatio(
aspectRatio: aspectRatio ?? 1,
child: CachedNetworkImage(
fadeInDuration: Duration.zero,
placeholder: (resizedUrl != null)
? (context, url) => CachedNetworkImage(
imageBuilder: (context, imageProvider) =>
DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: imageProvider,
fit: BoxFit.contain,
),
),
),
imageUrl: resizedUrl,
)
: null,
imageBuilder: (context, imageProvider) => DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: imageProvider,
fit: BoxFit.contain,
),
),
),
imageUrl: fullSizeUrl,
),
),
),
),
),
);
}
}
Код выше - немного особенный. Здесь мы делаем следующее:
получаем данные о URL адресах обычного изображения и его уменьшенной версии, а также данные о соотношении его сторон;
возвращаем кэшированное изображение CachedNetworkImage с URL адресом изображения в полном разрешении, и устанавливаем заглушку
placeholder
в качестве текущего CachedNetworkImage в случае для уменьшенного изображение, которое уже было кэшировано;используем виджет AspectRatio, который отвечает за то, чтобы оба изображения (полного разрешения и уменьшенного) занимали одинаковую площадь экрана;
для
fadeInDuration
устанавливаем значениеDuration.zero
.оборачиваем всё в виджете Hero, используя CustomRectTween для аргумента
createRectTween
.оборачиваем все в виджете InteractiveViewer для того, чтобы пользователи могли увеличивать и перемещаться по изображению.
Вышеописанный код обеспечивает не только плавный переход от кэшированного изображения маленького разрешения к изображению в максимальном разрешении (если оно уже было загружено), но и одновременно превращает этот переход в hero анимацию!