├── LICENSE ├── CHANGELOG.md ├── assets ├── holo.png └── sparkles.gif ├── test ├── assets │ └── test_image.png └── holo_card_effect_test.dart ├── analysis_options.yaml ├── .metadata ├── pubspec.yaml ├── .gitignore ├── README.md └── lib └── holo_card_effect.dart /LICENSE: -------------------------------------------------------------------------------- 1 | TODO: Add your license here. 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.1 2 | 3 | * TODO: Describe initial release. 4 | -------------------------------------------------------------------------------- /assets/holo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh-yeah-sea-kit2/holo_card_effect/HEAD/assets/holo.png -------------------------------------------------------------------------------- /assets/sparkles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh-yeah-sea-kit2/holo_card_effect/HEAD/assets/sparkles.gif -------------------------------------------------------------------------------- /test/assets/test_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh-yeah-sea-kit2/holo_card_effect/HEAD/test/assets/test_image.png -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | # Additional information about this file can be found at 4 | # https://dart.dev/guides/language/analysis-options 5 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "17025dd88227cd9532c33fa78f5250d548d87e9a" 8 | channel: "stable" 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: holo_card_effect 2 | description: "ホログラムカードのような光沢効果を実装するFlutterパッケージです。傾きに応じて変化するホログラム効果、カスタマイズ可能な光沢エフェクトを提供します。" 3 | version: 0.0.1 4 | homepage: https://github.com/oh-yeah-sea-kit2/holo_card_effect 5 | 6 | environment: 7 | sdk: ^3.6.0 8 | flutter: ">=1.17.0" 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | 14 | dev_dependencies: 15 | flutter_test: 16 | sdk: flutter 17 | flutter_lints: ^5.0.0 18 | 19 | flutter: 20 | # To add assets to your package, add an assets section, like this: 21 | assets: 22 | - assets/sparkles.gif 23 | - assets/holo.png 24 | - test/assets/test_image.png 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. 26 | /pubspec.lock 27 | **/doc/api/ 28 | .dart_tool/ 29 | .flutter-plugins 30 | .flutter-plugins-dependencies 31 | build/ 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # holo_card_effect 2 | 3 | ホログラムカードのような光沢効果を実装するFlutterパッケージです。 4 | 5 | ## 概要 6 | 7 | このパッケージは、トレーディングカードやメンバーシップカードなどのUIに、美しいホログラム効果を簡単に実装できるようにします。デバイスの傾きに応じて変化する光沢効果により、リアルなホログラムカードの見た目を実現します。 8 | 9 | ## 特徴 10 | 11 | - 傾きに応じて変化するホログラム効果 12 | - カスタマイズ可能な光沢エフェクト 13 | - シンプルな実装で美しい視覚効果を実現 14 | - パフォーマンスを考慮した設計 15 | 16 | ## インストール 17 | 18 | `pubspec.yaml`に以下を追加してください: 19 | 20 | ```yaml 21 | dependencies: 22 | holo_card_effect: ^0.0.1 23 | ``` 24 | 25 | ## 使い方 26 | 27 | 基本的な使用例: 28 | 29 | ```dart 30 | import 'package:holo_card_effect/holo_card_effect.dart'; 31 | 32 | HoloCard( 33 | imageUrl: 'assets/card_image.png', 34 | width: 300, 35 | height: 420, 36 | ) 37 | ``` 38 | 39 | カスタマイズ例: 40 | 41 | ```dart 42 | HoloCard( 43 | imageUrl: 'assets/card_image.png', 44 | width: 300, 45 | height: 420, 46 | showGlitter: true, 47 | showHolo: true, 48 | showRainbow: true, 49 | showShadow: true, 50 | showGloss: true, 51 | ) 52 | ``` 53 | 54 | ## プロパティ 55 | 56 | | プロパティ | 型 | デフォルト値 | 説明 | 57 | |------------|------|---------|------| 58 | | imageUrl | String | 必須 | カード画像のパス | 59 | | width | double | 300 | カードの幅 | 60 | | height | double | 420 | カードの高さ | 61 | | showGlitter | bool | true | キラキラエフェクトの表示 | 62 | | showHolo | bool | true | ホログラムエフェクトの表示 | 63 | | showRainbow | bool | true | 虹色エフェクトの表示 | 64 | | showShadow | bool | true | 影の表示 | 65 | | showGloss | bool | true | 光沢エフェクトの表示 | 66 | 67 | ## 使用例 68 | 69 | トレーディングカード、会員証、チケットなど、様々なカード型UIに適用可能です: 70 | 71 | - コレクションカード 72 | - デジタルチケット 73 | - メンバーシップカード 74 | - ギフトカード 75 | など 76 | 77 | ## デモ 78 | 79 | https://github.com/user-attachments/assets/77740f38-d2b5-4ba1-8424-9a3f2d1a067a 80 | 81 | ## 貢献 82 | 83 | バグ報告や機能リクエストは[GitHubのIssues](https://github.com/oh-yeah-sea-kit2/holo_card_effect/issues)にお願いします。 84 | -------------------------------------------------------------------------------- /test/holo_card_effect_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:holo_card_effect/holo_card_effect.dart'; 4 | 5 | void main() { 6 | testWidgets('HoloCard - 基本的なレンダリングテスト', (WidgetTester tester) async { 7 | await tester.pumpWidget( 8 | const MaterialApp( 9 | home: Scaffold( 10 | body: Center( 11 | child: HoloCard( 12 | imageUrl: 'test/assets/test_image.png', 13 | showGlitter: false, 14 | showHolo: false, 15 | showRainbow: false, 16 | showGloss: false, 17 | ), 18 | ), 19 | ), 20 | ), 21 | ); 22 | 23 | expect(find.byType(HoloCard), findsOneWidget); 24 | 25 | // メインの画像のみを検証 26 | final mainImageFinder = find.descendant( 27 | of: find.byType(HoloCard), 28 | matching: find.byType(Image), 29 | ); 30 | expect(mainImageFinder, findsOneWidget); 31 | }); 32 | 33 | testWidgets('HoloCard - エフェクトの表示/非表示テスト', (WidgetTester tester) async { 34 | await tester.pumpWidget( 35 | const MaterialApp( 36 | home: Scaffold( 37 | body: Center( 38 | child: HoloCard( 39 | imageUrl: 'test/assets/test_image.png', 40 | showGlitter: false, 41 | showHolo: false, 42 | showRainbow: false, 43 | showShadow: false, 44 | showGloss: false, 45 | ), 46 | ), 47 | ), 48 | ), 49 | ); 50 | 51 | final holoCardFinder = find.byType(HoloCard); 52 | final holoCard = tester.widget(holoCardFinder); 53 | 54 | expect(holoCard.showGlitter, false); 55 | expect(holoCard.showHolo, false); 56 | expect(holoCard.showRainbow, false); 57 | expect(holoCard.showShadow, false); 58 | expect(holoCard.showGloss, false); 59 | }); 60 | 61 | testWidgets('HoloCard - サイズ指定テスト', (WidgetTester tester) async { 62 | const double testWidth = 200.0; 63 | const double testHeight = 300.0; 64 | 65 | await tester.pumpWidget( 66 | const MaterialApp( 67 | home: Scaffold( 68 | body: Center( 69 | child: HoloCard( 70 | imageUrl: 'test/assets/test_image.png', 71 | width: testWidth, 72 | height: testHeight, 73 | ), 74 | ), 75 | ), 76 | ), 77 | ); 78 | 79 | final container = 80 | find.byType(Container).evaluate().first.widget as Container; 81 | expect(container.constraints?.maxWidth, testWidth); 82 | expect(container.constraints?.maxHeight, testHeight); 83 | }); 84 | 85 | test('HoloCard - isNetworkImage メソッドのテスト', () { 86 | const holoCard = HoloCard(imageUrl: 'https://example.com/image.jpg'); 87 | expect(holoCard.isNetworkImage, true); 88 | 89 | const localHoloCard = HoloCard(imageUrl: 'test/assets/test_image.png'); 90 | expect(localHoloCard.isNetworkImage, false); 91 | 92 | const dataUrlHoloCard = HoloCard(imageUrl: 'data:image/png;base64,xyz'); 93 | expect(dataUrlHoloCard.isNetworkImage, true); 94 | }); 95 | 96 | testWidgets('HoloCard - ジェスチャー処理テスト', (WidgetTester tester) async { 97 | await tester.pumpWidget( 98 | const MaterialApp( 99 | home: Scaffold( 100 | body: Center( 101 | child: SizedBox( 102 | width: 300, 103 | height: 420, 104 | child: HoloCard( 105 | imageUrl: 'test/assets/test_image.png', 106 | ), 107 | ), 108 | ), 109 | ), 110 | ), 111 | ); 112 | 113 | // ジェスチャーの開始位置を中心に設定 114 | final center = tester.getCenter(find.byType(HoloCard)); 115 | 116 | // ジェスチャーをシミュレート 117 | final gesture = await tester.createGesture(); 118 | await gesture.down(center); // ジェスチャーの開始を明示的に設定 119 | await gesture.moveTo(center + const Offset(100, 100)); 120 | await tester.pump(); 121 | 122 | // HoloCardウィジェット内のTransformを検証 123 | final transformFinder = find.descendant( 124 | of: find.byType(HoloCard), 125 | matching: find.byType(Transform), 126 | ); 127 | expect(transformFinder, findsWidgets); // 1つ以上のTransformが存在することを確認 128 | 129 | await gesture.up(); 130 | await tester.pumpAndSettle(); // アニメーションが完了するまで待機 131 | }); 132 | } 133 | -------------------------------------------------------------------------------- /lib/holo_card_effect.dart: -------------------------------------------------------------------------------- 1 | library; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'dart:math' as math; 5 | 6 | class HoloCard extends StatefulWidget { 7 | final String imageUrl; 8 | final double width; 9 | final double height; 10 | final bool showGlitter; 11 | final bool showHolo; 12 | final bool showRainbow; 13 | final bool showShadow; 14 | final bool showGloss; 15 | 16 | const HoloCard({ 17 | super.key, 18 | required this.imageUrl, 19 | this.width = 300, 20 | this.height = 420, 21 | this.showGlitter = true, 22 | this.showHolo = true, 23 | this.showRainbow = true, 24 | this.showShadow = true, 25 | this.showGloss = true, 26 | }); 27 | 28 | // URLかどうかを判定するヘルパーメソッド 29 | bool get isNetworkImage { 30 | return imageUrl.startsWith('http://') || 31 | imageUrl.startsWith('https://') || 32 | imageUrl.startsWith('data:'); 33 | } 34 | 35 | @override 36 | State createState() => _HoloCardState(); 37 | } 38 | 39 | class _HoloCardState extends State { 40 | double _rotationX = 0; 41 | double _rotationY = 0; 42 | double _rotationZ = 0; 43 | 44 | void _onPanUpdate(DragUpdateDetails details) { 45 | setState(() { 46 | final screenSize = MediaQuery.of(context).size; 47 | final centerX = screenSize.width / 2; 48 | final centerY = screenSize.height / 2; 49 | 50 | final dx = details.globalPosition.dx - centerX; 51 | final dy = details.globalPosition.dy - centerY; 52 | 53 | _rotationY = (dx / centerX * 35).clamp(-35, 35); 54 | _rotationX = (dy / centerY * 35).clamp(-35, 35); 55 | _rotationZ = (dx * dy / (centerX * centerY) * 20).clamp(-20, 20); 56 | }); 57 | } 58 | 59 | void _onPanEnd(DragEndDetails details) { 60 | setState(() { 61 | _rotationX = 0; 62 | _rotationY = 0; 63 | _rotationZ = 0; 64 | }); 65 | } 66 | 67 | Widget _buildGlossEffect() { 68 | return Container( 69 | decoration: BoxDecoration( 70 | gradient: LinearGradient( 71 | begin: Alignment( 72 | -0.2 - (_rotationY / 35), 73 | -0.2 - (_rotationX / 35), 74 | ), 75 | end: Alignment( 76 | 0.2 - (_rotationY / 35), 77 | 0.2 - (_rotationX / 35), 78 | ), 79 | colors: [ 80 | Colors.white.withOpacity(0.0), 81 | Colors.white.withOpacity( 82 | ((_rotationY.abs() + _rotationX.abs()) / 70) * 0.7, 83 | ), 84 | Colors.white.withOpacity(0.0), 85 | ], 86 | stops: const [0.2, 0.5, 0.8], 87 | ), 88 | ), 89 | ); 90 | } 91 | 92 | Widget _buildPlasticGlossEffect() { 93 | return Container( 94 | decoration: BoxDecoration( 95 | gradient: LinearGradient( 96 | begin: Alignment( 97 | -1.0 - (_rotationY / 35), 98 | -1.0 - (_rotationX / 35), 99 | ), 100 | end: Alignment( 101 | 1.0 - (_rotationY / 35), 102 | 1.0 - (_rotationX / 35), 103 | ), 104 | colors: [ 105 | Colors.white.withOpacity(0.0), 106 | Colors.white.withOpacity( 107 | ((_rotationY.abs() + _rotationX.abs()) / 70) * 0.3, 108 | ), 109 | Colors.white.withOpacity( 110 | ((_rotationY.abs() + _rotationX.abs()) / 70) * 0.6, 111 | ), 112 | Colors.white.withOpacity(0.0), 113 | ], 114 | stops: const [0.0, 0.3, 0.5, 1.0], 115 | ), 116 | ), 117 | ); 118 | } 119 | 120 | Widget _buildEdgeHighlight() { 121 | return Container( 122 | decoration: BoxDecoration( 123 | border: Border.all( 124 | color: Colors.white.withOpacity( 125 | ((_rotationY.abs() + _rotationX.abs()) / 70) * 0.5, 126 | ), 127 | width: 1, 128 | ), 129 | borderRadius: BorderRadius.circular(15), 130 | gradient: RadialGradient( 131 | center: Alignment( 132 | (_rotationY / 35), 133 | (_rotationX / 35), 134 | ), 135 | focal: Alignment( 136 | (_rotationY / 70), 137 | (_rotationX / 70), 138 | ), 139 | colors: [ 140 | Colors.white.withOpacity(0.0), 141 | Colors.white.withOpacity( 142 | ((_rotationY.abs() + _rotationX.abs()) / 70) * 0.2, 143 | ), 144 | ], 145 | stops: const [0.8, 1.0], 146 | ), 147 | ), 148 | ); 149 | } 150 | 151 | Widget _buildGlitterEffect() { 152 | return Opacity( 153 | opacity: 0.3 * ((_rotationY.abs() + _rotationX.abs()) / 70), 154 | child: ShaderMask( 155 | shaderCallback: (bounds) => LinearGradient( 156 | colors: [ 157 | Colors.white.withOpacity(0.0), 158 | Colors.white.withOpacity(0.2), 159 | Colors.white.withOpacity(0.0), 160 | ], 161 | stops: const [0.0, 0.5, 1.0], 162 | begin: Alignment( 163 | -1 + (_rotationY / 35), 164 | -1 + (_rotationX / 35), 165 | ), 166 | end: Alignment( 167 | 1 + (_rotationY / 35), 168 | 1 + (_rotationX / 35), 169 | ), 170 | ).createShader(bounds), 171 | blendMode: BlendMode.overlay, 172 | child: ShaderMask( 173 | shaderCallback: (bounds) => LinearGradient( 174 | colors: [ 175 | Colors.transparent, 176 | Colors.white.withOpacity(0.1), 177 | Colors.transparent, 178 | ], 179 | stops: const [0.0, 0.5, 1.0], 180 | ).createShader(bounds), 181 | blendMode: BlendMode.plus, 182 | child: Image.asset( 183 | 'assets/sparkles.gif', 184 | package: 'holo_card_effect', 185 | fit: BoxFit.cover, 186 | width: double.infinity, 187 | height: double.infinity, 188 | ), 189 | ), 190 | ), 191 | ); 192 | } 193 | 194 | Widget _buildHoloEffect() { 195 | return Opacity( 196 | opacity: 0.7 * ((_rotationY.abs() + _rotationX.abs()) / 70), 197 | child: ShaderMask( 198 | shaderCallback: (bounds) => LinearGradient( 199 | colors: [ 200 | Colors.white.withOpacity(0.0), 201 | Colors.white.withOpacity(0.7), 202 | Colors.white.withOpacity(0.0), 203 | ], 204 | stops: const [0.0, 0.5, 1.0], 205 | begin: Alignment( 206 | -1 + (_rotationY / 35), 207 | -1 + (_rotationX / 35), 208 | ), 209 | end: Alignment( 210 | 1 + (_rotationY / 35), 211 | 1 + (_rotationX / 35), 212 | ), 213 | ).createShader(bounds), 214 | blendMode: BlendMode.screen, 215 | child: ShaderMask( 216 | shaderCallback: (bounds) => LinearGradient( 217 | colors: [ 218 | Colors.transparent, 219 | Colors.white.withOpacity(0.5), 220 | Colors.transparent, 221 | ], 222 | stops: const [0.0, 0.5, 1.0], 223 | ).createShader(bounds), 224 | blendMode: BlendMode.overlay, 225 | child: Image.asset( 226 | 'assets/holo.png', 227 | package: 'holo_card_effect', 228 | fit: BoxFit.cover, 229 | width: double.infinity, 230 | height: double.infinity, 231 | color: Colors.white.withOpacity(0.7), 232 | colorBlendMode: BlendMode.hardLight, 233 | ), 234 | ), 235 | ), 236 | ); 237 | } 238 | 239 | Widget _buildRainbowEffect() { 240 | return ShaderMask( 241 | shaderCallback: (bounds) => LinearGradient( 242 | colors: [ 243 | const Color(0xFFff0084) 244 | .withOpacity(0.35 * ((_rotationY.abs() + _rotationX.abs()) / 70)), 245 | const Color(0xFFfca400) 246 | .withOpacity(0.3 * ((_rotationY.abs() + _rotationX.abs()) / 70)), 247 | const Color(0xFFffff00) 248 | .withOpacity(0.25 * ((_rotationY.abs() + _rotationX.abs()) / 70)), 249 | const Color(0xFF00ff8a) 250 | .withOpacity(0.25 * ((_rotationY.abs() + _rotationX.abs()) / 70)), 251 | const Color(0xFF00cfff) 252 | .withOpacity(0.3 * ((_rotationY.abs() + _rotationX.abs()) / 70)), 253 | const Color(0xFFcc4cfa) 254 | .withOpacity(0.35 * ((_rotationY.abs() + _rotationX.abs()) / 70)), 255 | ], 256 | begin: Alignment( 257 | -1.2 + (_rotationY / 35), 258 | -1.2 + (_rotationX / 35), 259 | ), 260 | end: Alignment( 261 | 1.2 + (_rotationY / 35), 262 | 1.2 + (_rotationX / 35), 263 | ), 264 | ).createShader(bounds), 265 | blendMode: BlendMode.overlay, 266 | child: Container( 267 | decoration: BoxDecoration( 268 | gradient: RadialGradient( 269 | center: Alignment( 270 | (_rotationY / 35), 271 | (_rotationX / 35), 272 | ), 273 | focal: Alignment( 274 | (_rotationY / 70), 275 | (_rotationX / 70), 276 | ), 277 | colors: [ 278 | Colors.white.withOpacity(0.4), 279 | Colors.transparent, 280 | ], 281 | stops: const [0.0, 0.9], 282 | ), 283 | ), 284 | ), 285 | ); 286 | } 287 | 288 | @override 289 | Widget build(BuildContext context) { 290 | return GestureDetector( 291 | onPanUpdate: _onPanUpdate, 292 | onPanEnd: _onPanEnd, 293 | child: Transform( 294 | transform: Matrix4.identity() 295 | ..setEntry(3, 2, 0.001) 296 | ..rotateX(_rotationX * math.pi / 180) 297 | ..rotateY(_rotationY * math.pi / 180) 298 | ..rotateZ(_rotationZ * math.pi / 180), 299 | alignment: Alignment.center, 300 | child: Container( 301 | width: widget.width, 302 | height: widget.height, 303 | decoration: BoxDecoration( 304 | borderRadius: BorderRadius.circular(15), 305 | boxShadow: widget.showShadow 306 | ? [ 307 | BoxShadow( 308 | color: Colors.black.withOpacity(0.2), 309 | blurRadius: 30, 310 | spreadRadius: -5, 311 | offset: const Offset(0, 0), 312 | ), 313 | BoxShadow( 314 | color: Color.lerp( 315 | const Color(0xFFfac), 316 | const Color(0xFFddccaa), 317 | 0.5, 318 | )! 319 | .withOpacity(0.3), 320 | blurRadius: 45, 321 | spreadRadius: -8, 322 | offset: const Offset(0, 0), 323 | ), 324 | BoxShadow( 325 | color: const Color(0xFFfac).withOpacity(0.4), 326 | blurRadius: 30, 327 | spreadRadius: -5, 328 | offset: const Offset(-15, -15), 329 | ), 330 | BoxShadow( 331 | color: const Color(0xFFddccaa).withOpacity(0.4), 332 | blurRadius: 30, 333 | spreadRadius: -5, 334 | offset: const Offset(15, 15), 335 | ), 336 | BoxShadow( 337 | color: Colors.white.withOpacity(0.2), 338 | blurRadius: 60, 339 | spreadRadius: -10, 340 | offset: const Offset(0, 0), 341 | ), 342 | ] 343 | : null, 344 | ), 345 | child: ClipRRect( 346 | borderRadius: BorderRadius.circular(15), 347 | child: Stack( 348 | children: [ 349 | widget.isNetworkImage 350 | ? Image.network( 351 | widget.imageUrl, 352 | fit: BoxFit.cover, 353 | width: double.infinity, 354 | height: double.infinity, 355 | ) 356 | : Image.asset( 357 | widget.imageUrl, 358 | fit: BoxFit.cover, 359 | width: double.infinity, 360 | height: double.infinity, 361 | ), 362 | if (widget.showGloss) ...[ 363 | _buildGlossEffect(), 364 | _buildPlasticGlossEffect(), 365 | ], 366 | _buildEdgeHighlight(), 367 | if (widget.showGlitter) _buildGlitterEffect(), 368 | if (widget.showHolo) _buildHoloEffect(), 369 | if (widget.showRainbow) _buildRainbowEffect(), 370 | ], 371 | ), 372 | ), 373 | ), 374 | ), 375 | ); 376 | } 377 | } 378 | --------------------------------------------------------------------------------