Dead Code/[toy] click motion

[플러터] 윈도우 마우스 클릭 애니메이션 (lottie) / 소스코드

2025. 3. 27.



lottie 애니메이션으로 윈도우 마우스 클릭 애니메이션을 만드는 내용과 관련된 개요는 아래 글에서 확인이 가능하다.

 

 

 

우선 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);
}