Skip to content

Commit c4db17f

Browse files
committed
feat: frameless window for mac
1 parent ef0d59f commit c4db17f

File tree

7 files changed

+183
-18
lines changed

7 files changed

+183
-18
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'package:flutter/services.dart';
2+
import 'package:flutter/material.dart';
3+
import 'dart:io' show Platform;
4+
5+
class CocoaWindowChannel {
6+
CocoaWindowChannel._();
7+
8+
final MethodChannel _channel = const MethodChannel("flutter/cocoaWindow");
9+
10+
static final CocoaWindowChannel instance = CocoaWindowChannel._();
11+
12+
Future<void> setWindowPosition(Offset offset) async {
13+
await _channel.invokeMethod("setWindowPosition", [offset.dx, offset.dy]);
14+
}
15+
16+
Future<List<double>> getWindowPosition() async {
17+
final raw = await _channel.invokeMethod("getWindowPosition");
18+
final arr = raw as List<dynamic>;
19+
final List<double> result = arr.map((s) => s as double).toList();
20+
return result;
21+
}
22+
23+
Future<void> zoom() async {
24+
await _channel.invokeMethod("zoom");
25+
}
26+
}
27+
28+
class MoveWindowDetector extends StatefulWidget {
29+
const MoveWindowDetector({Key? key, this.child}) : super(key: key);
30+
31+
final Widget? child;
32+
33+
@override
34+
_MoveWindowDetectorState createState() => _MoveWindowDetectorState();
35+
}
36+
37+
class _MoveWindowDetectorState extends State<MoveWindowDetector> {
38+
double winX = 0;
39+
double winY = 0;
40+
41+
@override
42+
Widget build(BuildContext context) {
43+
if (!Platform.isMacOS) {
44+
return widget.child ?? Container();
45+
}
46+
return GestureDetector(
47+
// https://stackoverflow.com/questions/52965799/flutter-gesturedetector-not-working-with-containers-in-stack
48+
behavior: HitTestBehavior.translucent,
49+
onDoubleTap: () async {
50+
await CocoaWindowChannel.instance.zoom();
51+
},
52+
onPanStart: (DragStartDetails details) {
53+
winX = details.globalPosition.dx;
54+
winY = details.globalPosition.dy;
55+
},
56+
onPanUpdate: (DragUpdateDetails details) async {
57+
final windowPos = await CocoaWindowChannel.instance.getWindowPosition();
58+
final double dx = windowPos[0];
59+
final double dy = windowPos[1];
60+
final deltaX = details.globalPosition.dx - winX;
61+
final deltaY = details.globalPosition.dy - winY;
62+
await CocoaWindowChannel.instance.setWindowPosition(Offset(dx + deltaX, dy - deltaY));
63+
},
64+
child: widget.child,
65+
);
66+
}
67+
}

frontend/app_flowy/lib/workspace/application/home/home_bloc.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
4949
unauthorized: (_Unauthorized value) {
5050
emit(state.copyWith(unauthorized: true));
5151
},
52+
collapseMenu: (e) {
53+
emit(state.copyWith(isMenuCollapsed: !state.isMenuCollapsed));
54+
},
5255
);
5356
});
5457
}
@@ -77,6 +80,7 @@ class HomeEvent with _$HomeEvent {
7780
const factory HomeEvent.dismissEditPannel() = _DismissEditPannel;
7881
const factory HomeEvent.didReceiveWorkspaceSetting(CurrentWorkspaceSetting setting) = _DidReceiveWorkspaceSetting;
7982
const factory HomeEvent.unauthorized(String msg) = _Unauthorized;
83+
const factory HomeEvent.collapseMenu() = _CollapseMenu;
8084
}
8185

8286
@freezed
@@ -87,6 +91,7 @@ class HomeState with _$HomeState {
8791
required Option<EditPannelContext> pannelContext,
8892
required CurrentWorkspaceSetting workspaceSetting,
8993
required bool unauthorized,
94+
required bool isMenuCollapsed,
9095
}) = _HomeState;
9196

9297
factory HomeState.initial(CurrentWorkspaceSetting workspaceSetting) => HomeState(
@@ -95,5 +100,6 @@ class HomeState with _$HomeState {
95100
pannelContext: none(),
96101
workspaceSetting: workspaceSetting,
97102
unauthorized: false,
103+
isMenuCollapsed: false,
98104
);
99105
}

frontend/app_flowy/lib/workspace/application/menu/menu_bloc.dart

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
2525
listener.start(addAppCallback: _handleAppsOrFail);
2626
await _fetchApps(emit);
2727
},
28-
collapse: (e) async {
29-
final isCollapse = state.isCollapse;
30-
emit(state.copyWith(isCollapse: !isCollapse));
31-
},
3228
openPage: (e) async {
3329
emit(state.copyWith(plugin: e.plugin));
3430
},
@@ -94,7 +90,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
9490
@freezed
9591
class MenuEvent with _$MenuEvent {
9692
const factory MenuEvent.initial() = _Initial;
97-
const factory MenuEvent.collapse() = _Collapse;
9893
const factory MenuEvent.openPage(Plugin plugin) = _OpenPage;
9994
const factory MenuEvent.createApp(String name, {String? desc}) = _CreateApp;
10095
const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp;
@@ -104,14 +99,12 @@ class MenuEvent with _$MenuEvent {
10499
@freezed
105100
class MenuState with _$MenuState {
106101
const factory MenuState({
107-
required bool isCollapse,
108102
required List<App> apps,
109103
required Either<Unit, FlowyError> successOrFailure,
110104
required Plugin plugin,
111105
}) = _MenuState;
112106

113107
factory MenuState.initial() => MenuState(
114-
isCollapse: false,
115108
apps: [],
116109
successOrFailure: left(unit),
117110
plugin: makePlugin(pluginType: DefaultPlugin.blank.type()),

frontend/app_flowy/lib/workspace/presentation/home/home_stack.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import 'dart:io' show Platform;
2+
13
import 'package:app_flowy/startup/startup.dart';
4+
import 'package:app_flowy/workspace/application/home/home_bloc.dart';
25
import 'package:app_flowy/workspace/presentation/home/home_screen.dart';
36
import 'package:flowy_infra/theme.dart';
47
import 'package:flowy_sdk/log.dart';
58
import 'package:flutter/material.dart';
9+
import 'package:flutter_bloc/flutter_bloc.dart';
610
import 'package:provider/provider.dart';
711
import 'package:time/time.dart';
812
import 'package:fluttertoast/fluttertoast.dart';
@@ -11,6 +15,7 @@ import 'package:app_flowy/plugin/plugin.dart';
1115
import 'package:app_flowy/workspace/presentation/plugins/blank/blank.dart';
1216
import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
1317
import 'package:app_flowy/workspace/presentation/home/navigation.dart';
18+
import 'package:app_flowy/core/frameless_window.dart';
1419
import 'package:flowy_infra_ui/widget/spacing.dart';
1520
import 'package:flowy_infra_ui/style_widget/extension.dart';
1621
import 'package:flowy_infra/notifier.dart';
@@ -152,7 +157,7 @@ class HomeStackManager {
152157
child: Selector<HomeStackNotifier, Widget>(
153158
selector: (context, notifier) => notifier.titleWidget,
154159
builder: (context, widget, child) {
155-
return const HomeTopBar();
160+
return const MoveWindowDetector(child: HomeTopBar());
156161
},
157162
),
158163
);
@@ -191,6 +196,14 @@ class HomeTopBar extends StatelessWidget {
191196
child: Row(
192197
crossAxisAlignment: CrossAxisAlignment.center,
193198
children: [
199+
BlocBuilder<HomeBloc, HomeState>(
200+
buildWhen: ((previous, current) => previous.isMenuCollapsed != current.isMenuCollapsed),
201+
builder: (context, state) {
202+
if (state.isMenuCollapsed && Platform.isMacOS) {
203+
return const HSpace(80);
204+
}
205+
return const HSpace(0);
206+
}),
194207
const FlowyNavigation(),
195208
const HSpace(16),
196209
ChangeNotifierProvider.value(

frontend/app_flowy/lib/workspace/presentation/home/menu/menu.dart

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export './app/header/header.dart';
22
export './app/menu_app.dart';
33

4+
import 'dart:io' show Platform;
5+
import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
46
import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
57
import 'package:app_flowy/workspace/presentation/plugins/trash/menu.dart';
68
import 'package:flowy_infra/notifier.dart';
@@ -18,7 +20,9 @@ import 'package:expandable/expandable.dart';
1820
import 'package:flowy_infra/time/duration.dart';
1921
import 'package:app_flowy/startup/startup.dart';
2022
import 'package:app_flowy/workspace/application/menu/menu_bloc.dart';
21-
import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
23+
import 'package:app_flowy/workspace/application/home/home_bloc.dart';
24+
import 'package:app_flowy/core/frameless_window.dart';
25+
// import 'package:app_flowy/workspace/presentation/home/home_sizes.dart';
2226
import 'package:flowy_infra/image.dart';
2327
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
2428

@@ -59,10 +63,10 @@ class HomeMenu extends StatelessWidget {
5963
getIt<HomeStackManager>().setPlugin(state.plugin);
6064
},
6165
),
62-
BlocListener<MenuBloc, MenuState>(
63-
listenWhen: (p, c) => p.isCollapse != c.isCollapse,
66+
BlocListener<HomeBloc, HomeState>(
67+
listenWhen: (p, c) => p.isMenuCollapsed != c.isMenuCollapsed,
6468
listener: (context, state) {
65-
_collapsedNotifier.value = state.isCollapse;
69+
_collapsedNotifier.value = state.isMenuCollapsed;
6670
},
6771
)
6872
],
@@ -179,27 +183,37 @@ class MenuSharedState {
179183

180184
class MenuTopBar extends StatelessWidget {
181185
const MenuTopBar({Key? key}) : super(key: key);
186+
187+
Widget renderIcon(BuildContext context) {
188+
if (Platform.isMacOS) {
189+
return Container();
190+
}
191+
final theme = context.watch<AppTheme>();
192+
return (theme.isDark
193+
? svgWithSize("flowy_logo_dark_mode", const Size(92, 17))
194+
: svgWithSize("flowy_logo_with_text", const Size(92, 17)));
195+
}
196+
182197
@override
183198
Widget build(BuildContext context) {
184199
final theme = context.watch<AppTheme>();
185200
return BlocBuilder<MenuBloc, MenuState>(
186201
builder: (context, state) {
187202
return SizedBox(
188203
height: HomeSizes.topBarHeight,
189-
child: Row(
204+
child: MoveWindowDetector(
205+
child: Row(
190206
children: [
191-
(theme.isDark
192-
? svgWithSize("flowy_logo_dark_mode", const Size(92, 17))
193-
: svgWithSize("flowy_logo_with_text", const Size(92, 17))),
207+
renderIcon(context),
194208
const Spacer(),
195209
FlowyIconButton(
196210
width: 28,
197-
onPressed: () => context.read<MenuBloc>().add(const MenuEvent.collapse()),
211+
onPressed: () => context.read<HomeBloc>().add(const HomeEvent.collapseMenu()),
198212
iconPadding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
199213
icon: svgWidget("home/hide_menu", color: theme.iconColor),
200214
)
201215
],
202-
),
216+
)),
203217
);
204218
},
205219
);

frontend/app_flowy/lib/workspace/presentation/home/navigation.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:app_flowy/workspace/application/home/home_bloc.dart';
12
import 'package:app_flowy/workspace/presentation/home/home_stack.dart';
23
import 'package:flowy_infra/image.dart';
34
import 'package:flowy_infra/notifier.dart';
@@ -95,6 +96,7 @@ class FlowyNavigation extends StatelessWidget {
9596
width: 24,
9697
onPressed: () {
9798
notifier.value = false;
99+
ctx.read<HomeBloc>().add(const HomeEvent.collapseMenu());
98100
},
99101
iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2),
100102
icon: svgWidget("home/hide_menu", color: theme.iconColor),

frontend/app_flowy/macos/Runner/MainFlutterWindow.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,82 @@
11
import Cocoa
22
import FlutterMacOS
33

4+
private let kTrafficLightOffetTop = 22
5+
46
class MainFlutterWindow: NSWindow {
7+
func registerMethodChannel(flutterViewController: FlutterViewController) {
8+
let cocoaWindowChannel = FlutterMethodChannel(name: "flutter/cocoaWindow", binaryMessenger: flutterViewController.engine.binaryMessenger)
9+
cocoaWindowChannel.setMethodCallHandler({
10+
(call: FlutterMethodCall, result: FlutterResult) -> Void in
11+
if call.method == "setWindowPosition" {
12+
guard let position = call.arguments as? NSArray else {
13+
result(nil)
14+
return
15+
}
16+
let nX = position[0] as! NSNumber
17+
let nY = position[1] as! NSNumber
18+
let x = nX.doubleValue
19+
let y = nY.doubleValue
20+
21+
self.setFrameOrigin(NSPoint(x: x, y: y))
22+
result(nil)
23+
return
24+
} else if call.method == "getWindowPosition" {
25+
let frame = self.frame
26+
result([frame.origin.x, frame.origin.y])
27+
return
28+
} else if call.method == "zoom" {
29+
self.zoom(self)
30+
result(nil)
31+
return
32+
}
33+
34+
result(FlutterMethodNotImplemented)
35+
})
36+
}
37+
38+
func layoutTrafficLightButton(titlebarView: NSView, button: NSButton, offsetTop: CGFloat, offsetLeft: CGFloat) {
39+
button.translatesAutoresizingMaskIntoConstraints = false;
40+
titlebarView.addConstraint(NSLayoutConstraint.init(
41+
item: button,
42+
attribute: NSLayoutConstraint.Attribute.top, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.top, multiplier: 1, constant: offsetTop))
43+
titlebarView.addConstraint(NSLayoutConstraint.init(
44+
item: button,
45+
attribute: NSLayoutConstraint.Attribute.left, relatedBy: NSLayoutConstraint.Relation.equal, toItem: titlebarView, attribute: NSLayoutConstraint.Attribute.left, multiplier: 1, constant: offsetLeft))
46+
}
47+
48+
func layoutTrafficLights() {
49+
let closeButton = self.standardWindowButton(ButtonType.closeButton)!
50+
let minButton = self.standardWindowButton(ButtonType.miniaturizeButton)!
51+
let zoomButton = self.standardWindowButton(ButtonType.zoomButton)!
52+
let titlebarView = closeButton.superview!
53+
54+
self.layoutTrafficLightButton(titlebarView: titlebarView, button: closeButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 20)
55+
self.layoutTrafficLightButton(titlebarView: titlebarView, button: minButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 38)
56+
self.layoutTrafficLightButton(titlebarView: titlebarView, button: zoomButton, offsetTop: CGFloat(kTrafficLightOffetTop), offsetLeft: 56)
57+
58+
let customToolbar = NSTitlebarAccessoryViewController()
59+
let newView = NSView()
60+
newView.frame = NSRect(origin: CGPoint(), size: CGSize(width: 0, height: 40)) // only the height is cared
61+
customToolbar.view = newView
62+
self.addTitlebarAccessoryViewController(customToolbar)
63+
}
64+
565
override func awakeFromNib() {
666
let flutterViewController = FlutterViewController.init()
767
let windowFrame = self.frame
868
self.contentViewController = flutterViewController
69+
70+
self.registerMethodChannel(flutterViewController: flutterViewController)
71+
972
self.setFrame(windowFrame, display: true)
73+
self.titlebarAppearsTransparent = true
74+
self.titleVisibility = .hidden
75+
self.styleMask.insert(StyleMask.fullSizeContentView)
76+
self.isMovableByWindowBackground = true
77+
self.isMovable = false
78+
79+
self.layoutTrafficLights()
1080

1181
RegisterGeneratedPlugins(registry: flutterViewController)
1282

0 commit comments

Comments
 (0)