lottie 애니메이션으로 윈도우 마우스 클릭 애니메이션을 만드는 내용과 관련된 개요는 아래 글에서 확인이 가능하다.
related posting list
우선 visual studio에서 dll 파일로 빌드를 해야하는 cpp 파일 소스코드는 다음과 같다.
mouse_hook.cpp
#include <windows.h>
#include "pch.h"
#include <fstream>
#include <string>
HHOOK mouseHook;
std::string outputPath = "C:\\Temp\\click_position.txt";
LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode >= 0 && wParam == WM_LBUTTONDOWN) {
MSLLHOOKSTRUCT* mouseInfo = (MSLLHOOKSTRUCT*)lParam;
int x = mouseInfo->pt.x;
int y = mouseInfo->pt.y;
std::ofstream out(outputPath);
if (out.is_open()) {
out << x << "," << y;
out.close();
// log("Click detected: " + std::to_string(x) + "," + std::to_string(y));
}
}
return CallNextHookEx(mouseHook, nCode, wParam, lParam);
}
void SetClickHook() {
// log("SetClickHook called");
HMODULE hInstance = GetModuleHandle(NULL);
mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseProc, hInstance, 0);
if (mouseHook == NULL) {
//log("Failed to set mouse hook");
MessageBoxW(NULL, L"Failed to set hook", L"Error", MB_ICONERROR);
return;
}
//log("Mouse hook successfully set");
MSG msg;
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// log("Message loop exited");
}
extern "C" __declspec(dllexport) void RemoveClickHook() {
if (mouseHook) {
UnhookWindowsHookEx(mouseHook);
// log("Mouse hook removed");
}
}
DWORD WINAPI HookThreadProc(LPVOID) {
SetClickHook();
return 0;
}
extern "C" __declspec(dllexport) void StartHookThread() {
// log("Starting hook thread...");
CreateThread(NULL, 0, HookThreadProc, NULL, 0, NULL);
}
flutter side에서 구성해야하는 부분은 그렇게 많지는 않지만, dll 파일을 호출하는 것은 다소 생소한 부분이었다.
또한 듀얼모니터를 사용하더라도 (0,0)좌표에서부터 1920*1080 사이즈 내에서만 마우스 클릭 애니메이션이 나타나도록 정해주었다.
assets/click2.json 파일로 지정된 lottie 애니메이션의 이름은 맞게 수정해주면 된다. 또는 동적으로 모션을 select 하는 기능을 추가할 수 있을 것이다. 현재는 0.8초 미만의 애니메이션을 기본으로 한다. 아니라면 해당 duration 코드를 수정해주면 된다.
main.dart
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:window_manager/window_manager.dart';
//
//
// DLL 메서드 정의
typedef StartHookThreadC = Void Function();
typedef StartHookThreadDart = void Function();
//
//
// main
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
skipTaskbar: false,
alwaysOnTop: true,
fullScreen: true,
backgroundColor: Colors.transparent,
),
() async {
await windowManager.setIgnoreMouseEvents(true);
await windowManager.setPosition(Offset.zero);
await windowManager.show();
debugPrint("✅ Window manager initialized");
},
);
debugPrint("📦 Loading DLL...");
final dylib = DynamicLibrary.open('click_hooking.dll');
final StartHookThreadDart startHookThread = dylib
.lookupFunction<StartHookThreadC, StartHookThreadDart>('StartHookThread');
startHookThread();
debugPrint("🔗 Setting mouse hook...");
startHookThread();
debugPrint("✅ Mouse hook thread started");
runApp(const MyApp());
}
//
//
//MyApp
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: ClickEffectOverlay(),
);
},
),
);
}
}
class ClickEffectOverlay extends StatefulWidget {
const ClickEffectOverlay({super.key});
@override
State<ClickEffectOverlay> createState() => _ClickEffectOverlayState();
}
class _ClickEffectOverlayState extends State<ClickEffectOverlay> {
final List<_ClickEffect> _effects = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final size = MediaQuery.of(context).size;
debugPrint("📏 화면 크기: ${size.width} x ${size.height}");
});
_startListeningForMessages();
}
void _startListeningForMessages() {
debugPrint("📡 Start listening for click coordinates...");
Timer.periodic(const Duration(milliseconds: 100), (_) {
_readClickPosition();
});
}
void _readClickPosition() async {
final file = File('C:/Temp/click_position.txt');
if (await file.exists()) {
final content = await file.readAsString();
final parts = content.trim().split(',');
if (parts.length == 2) {
final xRaw = double.tryParse(parts[0]);
final yRaw = double.tryParse(parts[1]);
if (xRaw != null && yRaw != null && xRaw != 0 && yRaw != 0) {
// MediaQuery로 DPI 값 얻기
final dpiScale = MediaQuery.of(context).devicePixelRatio;
// 클릭 위치를 DPI 스케일에 맞게 변환
final x = xRaw / dpiScale;
final y = yRaw / dpiScale;
debugPrint('📍 Scaled click: x = $x, y = $y');
_showClickEffect(Offset(x, y));
await file.writeAsString('0,0'); // 중복 방지
}
}
}
}
void _showClickEffect(Offset offset) {
// 화면 크기 내에서만 애니메이션을 표시하도록 조건 추가
if (offset.dx < 0 || offset.dy < 0 || offset.dx > 1920 || offset.dy > 1080)
return;
setState(() {
_effects.add(_ClickEffect(offset));
});
Timer(const Duration(milliseconds: 800), () {
if (_effects.isNotEmpty) {
setState(() {
_effects.removeAt(0);
});
}
});
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final ratio = MediaQuery.of(context).devicePixelRatio;
debugPrint("📐 화면 크기: ${size.width} x ${size.height}, DPI: $ratio");
return Container(
color: Color.fromRGBO(1, 1, 1, 0.01),
child: Stack(
children: [
..._effects
.where(
(e) =>
e.offset.dx >= 0 &&
e.offset.dx <= 1920 &&
e.offset.dy >= 0 &&
e.offset.dy <= 1080,
)
.map((e) {
return Positioned(
left: e.offset.dx - 50,
top: e.offset.dy - 50,
child: SizedBox(
width: 100,
height: 100,
child: Lottie.asset('assets/click2.json'),
),
);
})
.toList(),
],
),
);
}
}
class _ClickEffect {
final Offset offset;
_ClickEffect(this.offset);
}