Skip to content

Instantly share code, notes, and snippets.

@clragon
Last active November 6, 2023 17:41
Show Gist options
  • Select an option

  • Save clragon/9c9f99eca93d3c0c07e7d7e6c50ab49d to your computer and use it in GitHub Desktop.

Select an option

Save clragon/9c9f99eca93d3c0c07e7d7e6c50ab49d to your computer and use it in GitHub Desktop.
Character Swapping Animation
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const Home(),
);
}
}
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final List<String> fruitsInDifferentLanguages = [
'apple',
'manzana',
'pomme',
'apfel',
'mela',
'яблоко',
'蘋果',
'りんご',
'사과',
'maçã',
'elma',
'تفاح',
'सेब',
'แอปเปิ้ล',
'táo',
'apel',
'frukt',
'fruktas',
'frukto',
'fruta',
'ผลไม้',
];
int currentFruitIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SwappingText(
text: fruitsInDifferentLanguages[currentFruitIndex],
style: Theme.of(context).textTheme.displayLarge,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
setState(() {
while (true) {
final next =
Random().nextInt(fruitsInDifferentLanguages.length);
if (next != currentFruitIndex) {
currentFruitIndex = next;
break;
}
}
});
},
child: const Text('Swap'),
),
],
),
),
);
}
}
class CharacterAnimator {
CharacterAnimator(this.source, this.target)
: random = Random(generateSeed(target));
final String source;
final String target;
final Random random;
static int generateSeed(String text) =>
text.codeUnits.fold(0, (prev, elem) => prev + elem);
List<int> determineSwaps() {
List<int> swaps = [];
int maxLength = max(source.length, target.length);
for (int i = 0; i < maxLength; i++) {
if (i >= source.length || i >= target.length || source[i] != target[i]) {
swaps.add(i);
}
}
return swaps;
}
final List<MapEntry<int, int>> printableRanges = [
const MapEntry(0x0020, 0x007E), // Basic Latin
const MapEntry(0x00A0, 0x00FF), // Latin-1 Supplement
const MapEntry(0x0100, 0x017F), // Latin Extended-A
const MapEntry(0x0180, 0x024F), // Latin Extended-B
const MapEntry(0x0250, 0x02AF), // IPA Extensions
const MapEntry(0x02B0, 0x02FF), // Spacing Modifier Letters
const MapEntry(0x0300, 0x036F), // Combining Diacritical Marks
const MapEntry(0x0370, 0x03FF), // Greek and Coptic
const MapEntry(0x0400, 0x04FF), // Cyrillic
const MapEntry(0x0500, 0x052F), // Cyrillic Supplement
const MapEntry(0x0530, 0x058F), // Armenian
const MapEntry(0x0590, 0x05FF), // Hebrew
const MapEntry(0x0600, 0x06FF), // Arabic
const MapEntry(0x0700, 0x074F), // Syriac
const MapEntry(0x0750, 0x077F), // Arabic Supplement
const MapEntry(0x0780, 0x07BF), // Thaana
const MapEntry(0x3040, 0x309F), // Hiragana
const MapEntry(0x30A0, 0x30FF), // Katakana
const MapEntry(0x4E00, 0x9FFF), // CJK Unified Ideographs
const MapEntry(0xAC00, 0xD7AF), // Hangul Syllables
];
String interpolateCharacters(int index, double progress) {
int startVal;
int endVal;
if (index >= source.length) {
if (progress == 0.0) {
startVal = getDefaultCharacterCloseToSource();
} else {
startVal = getDefaultCharacterCloseToSource(index);
}
endVal = target.codeUnitAt(index);
} else if (index >= target.length) {
return " ";
} else {
startVal = source.codeUnitAt(index);
endVal = target.codeUnitAt(index);
}
int interpolatedVal = startVal + ((endVal - startVal) * progress).round();
interpolatedVal = getClosestPrintableCharacter(interpolatedVal);
return String.fromCharCode(interpolatedVal);
}
int getDefaultCharacterCloseToSource([int? index]) {
int avgCharCode = source.isNotEmpty
? source.codeUnits.reduce((a, b) => a + b) ~/ source.length
: getRandomPrintableCharacter();
return getClosestPrintableCharacter(avgCharCode +
(index != null ? (random.nextInt(3) - 1) * (index % 10) : 0));
}
int getRandomPrintableCharacter() {
int randomRangeIndex = random.nextInt(printableRanges.length);
final range = printableRanges[randomRangeIndex];
return random.nextInt(range.value - range.key + 1) + range.key;
}
int getClosestPrintableCharacter(int codeUnit) {
for (final range in printableRanges) {
if (codeUnit >= range.key && codeUnit <= range.value) {
return codeUnit;
}
}
List<int> distances = [];
for (final range in printableRanges) {
if (codeUnit < range.key) {
distances.add(range.key - codeUnit);
} else if (codeUnit > range.value) {
distances.add(codeUnit - range.value);
}
}
distances.sort();
int closestDistance = distances.first;
return codeUnit + closestDistance;
}
String animateText(double progress) {
List<int> swaps = determineSwaps();
StringBuffer animatedText = StringBuffer();
for (int i = 0; i < swaps.length; i++) {
animatedText.write(interpolateCharacters(swaps[i], progress));
}
return animatedText.toString();
}
}
class SwapStringTween extends Tween<String> {
SwapStringTween({
String? begin,
required String end,
}) : super(begin: begin, end: end);
@override
String lerp(double t) {
String beginText = begin ?? '';
String endText = end ?? '';
CharacterAnimator animator = CharacterAnimator(
beginText,
endText,
);
return animator.animateText(t);
}
}
class SwappingText extends StatelessWidget {
const SwappingText({
super.key,
required this.text,
this.duration = const Duration(milliseconds: 200),
this.style,
});
final String text;
final Duration duration;
final TextStyle? style;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder(
duration: duration,
tween: SwapStringTween(
begin: text,
end: text,
),
builder: (context, text, child) => Text(
text,
style: style,
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment