2023/04/13
- interactiveViewerは、慣性スクロールが止められず、ずれてしまう。制御しづらいのでやはり厳しいか。(下記はinteractive_viewer_scroll_test)
flutterのソースファイル:main.dart
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final double boxSize = 100;
final double fixedRows = 1;
double xBasis = 0;
TransformationController _transformationController = TransformationController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Stack(
children: [
Container(
width: 1250,
height: 100,
color: Colors.yellow,
),
Positioned(
child: Container(
width: 1250,
height: 100,
color: Colors.red,
),
),
for (int x = 0; x < 15; x++)
for (double y = 0; y < 1; y++)
Positioned(
left: xBasis + x * boxSize,
top: 0 + (y * boxSize),
child: _buildCell(x, y, Colors.lightBlue),
),
],
),
Flexible(
child: Container(
width: 1250,
height: 800,
color: Colors.yellow,
child: Container(
width: 1500,
height: 800,
color: Colors.grey,
child: GestureDetector(
onPanUpdate: (pan) {
print('Change In X:${pan.delta.dx}');
print('Change In Y:${pan.delta.dy}');
print('Global X Coordinate:${pan.globalPosition.dx}');
print('Global Y Coordinate:${pan.globalPosition.dy}');
print('Local x Coordinate:${pan.localPosition.dx}');
print('Local Y Coordinate:${pan.localPosition.dy}');
},
onHorizontalDragUpdate: (details) {
;
},
child: InteractiveViewer(
constrained: false,
panEnabled: true,
minScale: 0.2,
maxScale: 5.0,
scaleFactor: 600,
scaleEnabled: true,
boundaryMargin: EdgeInsets.all(0),
transformationController: _transformationController,
onInteractionEnd: (details) => setState(() {}),
onInteractionUpdate: (details) {
//print('onInteractionEnd: ' + details.toString());
setState(() {
xBasis = -1 * _transformationController.toScene(details.focalPointDelta).dx;
});
},
child: Stack(
children: [
Container(
width: 1500,
height: 800,
),
for (int x = 0; x < 15; x++)
for (double y = 0; y < 5; y++)
Positioned(
left: x * boxSize,
top: 0 + (y * boxSize),
child: _buildCell(x, y, Colors.white),
),
],
),
),
),
),
),
),
]),
),
);
}
Widget _buildCell(int x, double y, Color color) {
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.black, width: 1),
color: color,
),
child: Center(child: Text('${x + 1}, ${y + 1}')),
);
}
}
2023/04/16
- タイトル行を固定し、それ以外の部分のみパンできるようにした
- マウスのホイールでのスクロールに対応した
- かなり大規模な修正だった。InteractiveViewerから脱却し、GestureDetectorで自分でパンするように変更。但しRelationのCustomPaintを載せるとスクロール出来なくなるので一時的に外した。拡大縮小も一時的に外した。
まだ拡大縮小の問題は残っているが、だいぶ前進した。
flutterのソースファイル:main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'logic_collection.dart';
import 'dart:math';
import 'dart:ui';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path/path.dart' as path_dart; //asが無いとcontextを上書きされてしまう
import 'package:file_selector/file_selector.dart';
class MyCustomScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch, // 通常のタッチ入力デバイス
PointerDeviceKind.mouse, // これを追加!
};
}
void main() => runApp(ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
scrollBehavior: MyCustomScrollBehavior(),
home: Scaffold(
body: SafeArea(child:
WholeZone(),
),
),
);
}
}
class WholeZone extends ConsumerStatefulWidget {
@override
_WholeZoneState createState() => _WholeZoneState();
}
class _WholeZoneState extends ConsumerState<WholeZone> {
GlobalKey<CanvasZoneState> canvasZoneKey = GlobalKey();
late CanvasZone canvasZone;
double x = 0.0;
double y = 0.0;
@override
void initState() {
canvasZone = CanvasZone(key: canvasZoneKey);
super.initState();
}
void _updateLocation(PointerEvent details) {
setState(() {
x = details.position.dx;
y = details.position.dy;
});
}
void addNewLane() => canvasZoneKey.currentState!.addNewLane();
void removeLane() => canvasZoneKey.currentState?.removeLane();
void test() => canvasZoneKey.currentState?.test();
void scaleUp() {
if (canvasZoneKey.currentState == null) return;
TransformationController controller = canvasZoneKey.currentState!._transformationController;
controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.1);
}
void scaleDown() {
if (canvasZoneKey.currentState == null) return;
TransformationController controller = canvasZoneKey.currentState!._transformationController;
controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 0.9);
}
void scaleReset() {
if (canvasZoneKey.currentState == null) return;
TransformationController controller = canvasZoneKey.currentState!._transformationController;
controller.value = Matrix4.identity();
}
Future<void> loadDataWithDialog() async {
const XTypeGroup typeGroup = XTypeGroup(
label: 'json',
extensions: ['json'],
);
final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
if (file != null) {
await loadData(fileName: file.path, state: canvasZoneKey.currentState);
//レーンの高さを取得
ref.watch(lanesBodyCanvasHight.notifier).state = canvasZoneKey.currentState!.getMaxLanesHight();
//スクロールポジションを戻す
canvasZoneKey.currentState!._scrollController.jumpTo(0);
}
}
Future<void> saveAs() async {
String? path = await getSavePath(
acceptedTypeGroups: [
const XTypeGroup(label: 'json', extensions: ['json'])
],
suggestedName: "",
);
if (path != null) {
if (path_dart.extension(path) == '') {
path = '$path.json';
}
saveData(fileName: path, state: canvasZoneKey.currentState);
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onHover: _updateLocation,
child: Column(children: [
Align(
alignment: Alignment.topLeft,
child: Container(
margin: const EdgeInsets.only(top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: addNewLane,
child: const Text("レーン追加"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: removeLane,
child: const Text("削除"),
),
const SizedBox(width: 30),
ElevatedButton(
onPressed: scaleUp,
child: const Text("拡大"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: scaleDown,
child: const Text("縮小"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: scaleReset,
child: const Text("リセット"),
),
const SizedBox(width: 30),
ElevatedButton(
onPressed: saveAs,
child: const Text("名前を付けて保存"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: loadDataWithDialog,
child: const Text("ファイルを読込"),
),
const SizedBox(width: 30),
ElevatedButton(
onPressed: test,
child: const Text("テスト"),
),
const SizedBox(width: 30),
Container( child: Text("x:${x.toInt().toString().padLeft(5,'0')}, y:${y.toInt().toString().padLeft(5,'0')} ") ),
],
),
),
),
const Divider(),
Expanded(
child: canvasZone,
),
]),
);
}
}
class IdeasRelation {
Idea origin;
Idea related;
IdeasRelation({required this.origin, required this.related});
}
class CanvasZone extends ConsumerStatefulWidget {
@override
GlobalKey<CanvasZoneState> key;
CanvasZone({required GlobalKey<CanvasZoneState> this.key}) : super(key: key);
@override
CanvasZoneState createState() => CanvasZoneState();
}
class CanvasZoneState extends ConsumerState<CanvasZone> {
double widthA = double.infinity;
List<LaneBody> lanes = [];
List<LaneHeader> laneHeaders = [];
List<IdeasRelation> relatedIdeasList = [];
final counterForReaint = ValueNotifier<int>(0);
TransformationController _transformationController = TransformationController();
GlobalKey _interactiveViewerKey = GlobalKey();
double laneWidth = 0;
double globalTop = 0.0;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
addNewLane();
loadDataAtStartUp();
getMaxLanesHight();
WidgetsBinding.instance.addPostFrameCallback((_) {
//CanvasのTop座標を取得(スクロール制御用)
globalTop = (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy;
lanes[0].ideas[0].key.currentState?._textFocusNode.requestFocus();
setState(() {});
});
}
void test() {
setState(() {});
}
double getMaxLanesHight() {
int maxIdeaLength = 1;
lanes.forEach((lane) {
if (lane.ideas.length > maxIdeaLength ) {
maxIdeaLength = lane.ideas.length;
}
});
return maxIdeaLength * ref.read(eachIdeaHight);
}
Future<void> loadDataAtStartUp() async {
await loadData(state: this);
//レーンの高さを取得
ref.watch(lanesBodyCanvasHight.notifier).state = getMaxLanesHight();
print("${lanes[0].ideas.length}, ${ref.watch(lanesBodyCanvasHight)}");
}
Future<void> addNewLaneAndFocusTitle(int targetLaneIndex) async {
if (targetLaneIndex == lanes.length) {
await addNewLane();
}
// ウィジェットが構築されるのを待つ
WidgetsBinding.instance.addPostFrameCallback((_) {
lanes[targetLaneIndex].key.currentState?._titleFocusNode.requestFocus();
});
}
Future<void> addNewLane() async {
setState(() {
var index = lanes.length;
var newLane = LaneBody(index: index, ideas: [Idea(index: 0)]);
lanes.add(newLane);
newLane.ideas[0].parentLane = newLane;
var newLaneHeader = LaneHeader(index: index, relatedLane: newLane);
laneHeaders.add(newLaneHeader);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
lanes[lanes.length - 1].ideas[0].key.currentState?._textFocusNode.requestFocus();
});
}
void removeLane() {
setState(() {
if (lanes.length > 1) {
lanes[lanes.length - 1].ideas.forEach((idea) {
lanes[lanes.length - 1].key.currentState?.removeFromRelation(idea);
});
lanes.removeLast();
laneHeaders.removeLast();
}
});
}
void focusNextLane(int targetLaneIndex, int targetIdeaIndex) {
if (targetLaneIndex < 0 || targetIdeaIndex < 0) return;
if (targetLaneIndex == lanes.length) {
addNewLane();
}
if (targetLaneIndex < lanes.length) {
for (int i = lanes[targetLaneIndex].ideas.length - 1; i < targetIdeaIndex; i++) {
if (lanes[targetLaneIndex].key.currentState == null) {
setState(() {
//addNewIdeaが使えない状態なので、やむなく自力実装
lanes[targetLaneIndex].ideas.add(Idea(index: i + 1));
lanes[targetLaneIndex].ideas[lanes[targetLaneIndex].ideas.length - 1].parentLane = lanes[targetLaneIndex];
for (int j = 0; j < lanes[targetLaneIndex].ideas.length; j++) {
lanes[targetLaneIndex].ideas[j].index = j;
}
});
} else {
lanes[targetLaneIndex].key.currentState?.addNewIdea(i);
}
}
lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus();
}
}
// 関連付けの線を描画する
void _relationLinePaint(Canvas canvas, Size size) {
relationLinePaint(canvas, _transformationController, widget, _interactiveViewerKey);
//relationLinePaintForDebug(canvas, widget);
}
@override
Widget build(BuildContext context) {
if(laneWidth == 0) {
laneWidth = MediaQuery.of(context).size.width * 0.4;
}
return CanvasZoneInheritedWidget(
canvasZoneState: this,
child: Column(
children: [
SizedBox( // タイトル行
width: laneWidth * lanes.length,
height: 50,
child: Stack(
children: [
Positioned(
top: 0,
left: ref.watch(canvasScrollDeltaX),
width: laneWidth * lanes.length,
height: ref.watch(headerHeight),
child: Row(
children: laneHeaders,
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
child: SizedBox(
width: laneWidth * lanes.length,
height: ref.watch(lanesBodyCanvasHight),
child: GestureDetector(
onPanUpdate: (details) { //タイトル行を一緒に動かす
setState(() {
ref.watch(canvasScrollDeltaX.notifier).state += details.delta.dx;
ref.watch(canvasScrollDeltaX.notifier).state = ref.watch(canvasScrollDeltaX).clamp(-laneWidth * lanes.length, 0);
double targetY = _scrollController.position.pixels - details.delta.dy;
double yRange;
double wholeHeight = globalTop + ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHight);
if(wholeHeight < MediaQuery.of(context).size.height) {
yRange = 0;
} else {
yRange = wholeHeight - MediaQuery.of(context).size.height + 16 + 25 /*スクロールのバウンス用*/ ;
}
_scrollController.jumpTo(targetY.clamp(0, yRange));
});
},
child:Stack(
children: [
Positioned( // Ideasの描画
top: 0,// + ref.watch(canvasScrollDeltaY),
left: 0 + ref.watch(canvasScrollDeltaX),
width: laneWidth * lanes.length,
height: ref.watch(lanesBodyCanvasHight),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: lanes.length,
itemBuilder: (context, index) {
return SizedBox( // Laneごとの箱
width: laneWidth,
child: lanes[index],
);
},
),
),
/*
Positioned(
top: 0,
left: 0,
width: laneWidth * lanes.length,
height: MediaQuery.of(context).size.height,
child: Container(
width: laneWidth * lanes.length,
height: double.infinity,
color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
child: CustomPaint(
painter: _CustomPainterB(_relationLinePaint, counterForReaint),
child: Container(
width: 50,
// canvasZoneKey.currentState!._transformationController;
// controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.1);widget.,
height: MediaQuery.of(context).size.height,
),
),
),
),
*/
],
),
),
),
),
),
]),
// ),
);
}
}
class CanvasZoneInheritedWidget extends InheritedWidget {
final CanvasZoneState canvasZoneState;
CanvasZoneInheritedWidget({required this.canvasZoneState, required Widget child}) : super(child: child);
@override
bool updateShouldNotify(CanvasZoneInheritedWidget oldWidget) => true;
static CanvasZoneState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<CanvasZoneInheritedWidget>()!.canvasZoneState;
}
}
class LaneHeader extends ConsumerStatefulWidget {
@override
final GlobalKey<_LaneHeaderState> key = GlobalKey<_LaneHeaderState>();
int index;
String title;
LaneBody relatedLane;
final double indentWidth = 40; //インデントの下げ幅
LaneHeader({
required this.index,
required this.relatedLane,
this.title = '',
}) : super(key: ValueKey(GlobalKey<_LaneHeaderState>().toString()));
@override
_LaneHeaderState createState() => _LaneHeaderState();
}
class _LaneHeaderState extends ConsumerState<LaneHeader> {
final TextEditingController titleController = TextEditingController();
final FocusNode _titleFocusNode = FocusNode();
@override
void initState() {
super.initState();
titleController.text = widget.title;
}
@override
Widget build(BuildContext context) {
return LaneHeaderInheritedWidget(
laneHeaderState: this,
child: SizedBox(
height: 50,
width: ref.watch(eachLaneWidth),
child: Focus(
onKey: (FocusNode node, RawKeyEvent event) {
if (event.runtimeType == RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) { //カーソルキー ↓
widget.relatedLane.ideas[0].key.currentState?._textFocusNode.requestFocus();
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
titleController.selection.baseOffset == titleController.text.length) {
CanvasZoneInheritedWidget.of(context).addNewLaneAndFocusTitle(widget.index + 1);
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && //カーソルキー ←
titleController.selection.baseOffset == 0) {
if (widget.index > 0) {
CanvasZoneInheritedWidget.of(context)
.lanes[widget.index - 1]
.key
.currentState
?._titleFocusNode
.requestFocus();
}
}
}
return KeyEventResult.ignored;
},
child: Container(
// Title
color: Colors.blue[50],
padding: EdgeInsets.symmetric(horizontal: widget.indentWidth),
child: TextField(
controller: titleController,
focusNode: _titleFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
disabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2.0),
),
fillColor: Colors.blue[50],
filled: true,
),
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold, color: Colors.blue[900]),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
_titleFocusNode.unfocus();
},
),
)),
),
);
}
}
class LaneHeaderInheritedWidget extends InheritedWidget {
final _LaneHeaderState laneHeaderState;
LaneHeaderInheritedWidget({required this.laneHeaderState, required Widget child}) : super(child: child);
@override
bool updateShouldNotify(LaneHeaderInheritedWidget oldWidget) => true;
static _LaneHeaderState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LaneHeaderInheritedWidget>()!.laneHeaderState;
}
}
class LaneBody extends ConsumerStatefulWidget {
@override
final GlobalKey<_LaneBodyState> key = GlobalKey<_LaneBodyState>();
int index;
String title;
List<Idea> ideas;
final double indentWidth = 40; //インデントの下げ幅
LaneBody({
required this.index,
required this.ideas,
this.title = '',
}) : super(key: ValueKey(GlobalKey<_LaneBodyState>().toString()));
@override
_LaneBodyState createState() => _LaneBodyState();
}
class _LaneBodyState extends ConsumerState<LaneBody> {
List<Idea> get ideas => widget.ideas;
final TextEditingController titleController = TextEditingController();
final FocusNode _titleFocusNode = FocusNode();
void addNewIdea(int currentIndex) {
setState(() {
int currentIndentLevel = ideas[currentIndex].indentLevel;
ideas.insert(
currentIndex + 1,
Idea(
index: currentIndex + 1,
parentLane: widget,
textFieldHeight: 48.0,
indentLevel: currentIndentLevel,
),
);
for (int i = currentIndex + 2; i < ideas.length; i++) {
ideas[i].index = i;
}
ref.watch(lanesBodyCanvasHight.notifier).state = CanvasZoneInheritedWidget.of(context).getMaxLanesHight();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
ideas[currentIndex + 1].key.currentState?._textFocusNode.requestFocus();
});
}
void moveIdea(int currentIndex, int direction) {
if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
setState(() {
Idea temp = ideas[currentIndex];
ideas[currentIndex] = ideas[currentIndex + direction];
ideas[currentIndex + direction] = temp;
ideas[currentIndex].key.currentState?.updateIndex(currentIndex);
ideas[currentIndex + direction].key.currentState?.updateIndex(currentIndex + direction);
});
}
}
void removeFromRelation(Idea targetIdea) {
List<IdeasRelation> relatedIdeasList = CanvasZoneInheritedWidget.of(context).relatedIdeasList;
List<IdeasRelation> removeTargetList = [];
if (relatedIdeasList.isNotEmpty) {
for (var relationSet in relatedIdeasList) {
if (relationSet.origin == targetIdea || relationSet.related == targetIdea) {
removeTargetList.add(relationSet);
}
}
}
if (removeTargetList.isNotEmpty) {
for (var relationSet in removeTargetList) {
relatedIdeasList.remove(relationSet);
}
CanvasZoneInheritedWidget.of(context).counterForReaint.value++;
}
}
void removeIdea(int currentIndex) {
if (ideas.length > 1) {
setState(() {
removeFromRelation(ideas[currentIndex]);
ideas.removeAt(currentIndex);
for (int i = currentIndex; i < ideas.length; i++) {
ideas[i].index = i;
}
ideas[max(0, currentIndex - 1)].key.currentState?._textFocusNode.requestFocus();
});
}
}
void focusAdjacentIdea(int currentIndex, int direction) {
if (currentIndex == 0 && direction <= 0) {
FocusScope.of(context).requestFocus(_titleFocusNode);
} else if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
ideas[currentIndex + direction].key.currentState?._textFocusNode.requestFocus();
}
}
void redrawAffectedIdeas(int startIndex, int endIndex) {
if (startIndex < 0 || endIndex >= ideas.length) return;
setState(() {
for (int i = startIndex; i <= endIndex; i++) {
//再描画を促す
//ideas[i].key.currentState?.indentLevel = widget.key.currentState?.indentLevel ?? indentLevel;
}
});
}
@override
void initState() {
super.initState();
titleController.text = widget.title;
}
@override
Widget build(BuildContext context) {
return LaneBodyInheritedWidget(
laneState: this,
child: SizedBox(
width: double.infinity,
height: (ref.watch(eachIdeaHight) * ideas.length).toDouble(),
child: Column(
children: ideas
),
),
);
}
}
class LaneBodyInheritedWidget extends InheritedWidget {
final _LaneBodyState laneState;
LaneBodyInheritedWidget({required this.laneState, required Widget child}) : super(child: child);
@override
bool updateShouldNotify(LaneBodyInheritedWidget oldWidget) => true;
static _LaneBodyState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LaneBodyInheritedWidget>()!.laneState;
}
}
class Idea extends ConsumerStatefulWidget {
@override
final GlobalKey<_IdeaState> key = GlobalKey<_IdeaState>();
int index;
int indentLevel;
final double boxSpacing=8.0; //他のエディタとの間隔
final double textFieldHeight; //テキスト欄の高さ
String content;
LaneBody? parentLane;
List<Idea> relatedIdeas = [];
GlobalKey textFieldKey = GlobalKey();
Idea({
required this.index,
this.parentLane,
this.textFieldHeight = 48.0, //デフォルトは48
this.indentLevel = 0,
this.content = '',
}) : super(key: ValueKey(GlobalKey<_IdeaState>().toString()));
@override
_IdeaState createState() => _IdeaState();
}
class _IdeaState extends ConsumerState<Idea> with AutomaticKeepAliveClientMixin<Idea> {
final TextEditingController textController = TextEditingController();
final FocusNode _textFocusNode = FocusNode();
void updateIndex(int newIndex) {
setState(() {
widget.index = newIndex;
});
}
// インデントの線を描画する
void _indentLinePaint(Canvas canvas, Size size) {
indentLinePaint(canvas, size, context, widget.index, widget.indentLevel, widget.textFieldHeight, widget.boxSpacing,
LaneBodyInheritedWidget.of(context).widget.indentWidth);
}
String toJson() {
Map<String, dynamic> data = {
'index': widget.index,
'indentLevel': widget.indentLevel,
'text': textController.text,
};
return json.encode(data);
}
@override
void initState() {
super.initState();
textController.text = widget.content;
}
@override
bool get wantKeepAlive => true; //これが無いと画面外から出たときに中身も消えてしまう
@override
Widget build(BuildContext context) {
super.build(context);
return Focus(
onKey: (FocusNode node, RawKeyEvent event) {
if (event.runtimeType == RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.capsLock) {
// CapsLockキーを無視する条件を追加
return KeyEventResult.ignored;
} else if (event.isControlPressed) {
//コントロールキー
bool needRepaint = false;
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
LaneBodyInheritedWidget.of(context).moveIdea(widget.index, -1);
needRepaint = true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
needRepaint = true;
}
if (needRepaint) {
findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index,
widget.index + 1 /*Ctrlキーの場合、1つ下からチェックが必要*/);
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
//カーソルキー ↑
LaneBodyInheritedWidget.of(context).focusAdjacentIdea(widget.index, -1);
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
if (widget.index == LaneBodyInheritedWidget.of(context).ideas.length - 1) {
LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
} else {
LaneBodyInheritedWidget.of(context).focusAdjacentIdea(widget.index, 1);
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
textController.selection.baseOffset == textController.text.length) {
int laneIndex =
CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex + 1, widget.index);
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && textController.selection.baseOffset == 0) {
int laneIndex =
CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex - 1, widget.index);
} else if (event.logicalKey == LogicalKeyboardKey.tab) {
//TABキー
if (event.isShiftPressed) {
setState(() {
widget.indentLevel = max(0, widget.indentLevel - 1);
});
} else {
setState(() {
widget.indentLevel += 1;
});
}
findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index, widget.index);
return KeyEventResult.handled; //デフォルトのタブの挙動を無効化して、フォーカスの移動を防ぐ
} else if ((event.logicalKey == LogicalKeyboardKey.delete || //DEL & BSキー
event.logicalKey == LogicalKeyboardKey.backspace) &&
textController.text.isEmpty) {
LaneBodyInheritedWidget.of(context).removeIdea(widget.index);
} else if (event.logicalKey == LogicalKeyboardKey.f1) {
StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
mode.state = true;
ref.watch(originIdeaForSelectMode.notifier).state = widget;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'IdeaSelectMode:${mode.state} Lane:${CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget)} Idea:${widget.index}'),
),
);
}
}
return KeyEventResult.ignored;
},
child: RawKeyboardListener(
focusNode: FocusNode(),
child: Container(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: widget.boxSpacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(width: 26, child: DecoratedBox(decoration: BoxDecoration(color: Colors.red))),
Expanded(
flex: 4,
child: Row(children: [
CustomPaint(
painter: _CustomPainter(_indentLinePaint),
child: Container(width: LaneBodyInheritedWidget.of(context).widget.indentWidth * widget.indentLevel),
),
Expanded(
child: SizedBox(
height: widget.textFieldHeight,
child: TextField(
key: widget.textFieldKey,
controller: textController,
focusNode: _textFocusNode,
maxLines: 1,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
Offset? globalOffset;
if (ref.watch(relatedIdeaSelectMode.notifier).state == true) {
StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
Idea? origin = ref.watch(originIdeaForSelectMode.notifier).state;
if (origin != null) {
widget.relatedIdeas.add(origin);
CanvasZoneInheritedWidget.of(context)
.widget
.key
.currentState!
.relatedIdeasList
.add(IdeasRelation(origin: origin, related: widget));
origin = null;
RenderBox? box = widget.key.currentContext?.findRenderObject() as RenderBox;
globalOffset = box.localToGlobal(Offset.zero);
}
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'globaolOffset:$globalOffset | originIndex:${widget.relatedIdeas[0].index} | Lane:${CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget)} Idea:${widget.index}'),
),
);
mode.state = false;
} else {
_textFocusNode.unfocus();
LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
}
},
),
),
),
]),
),
],
),
),
));
}
}
class _CustomPainter extends CustomPainter {
final void Function(Canvas, Size) _paint;
_CustomPainter(this._paint);
@override
void paint(Canvas canvas, Size size) {
_paint(canvas, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _CustomPainterB extends CustomPainter {
final void Function(Canvas, Size) _paint;
_CustomPainterB(this._paint, Listenable repaint) : super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
_paint(canvas, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
logic_collection.dart (main.dartと同じフォルダに配置)
import 'package:flutter/material.dart';
import 'main.dart';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'package:vector_math/vector_math_64.dart' as vector_math;
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
StateProvider<bool> relatedIdeaSelectMode = StateProvider((ref) => false);
StateProvider<Idea?> originIdeaForSelectMode = StateProvider((ref) => null);
StateProvider<double> currentScale = StateProvider((ref) => 1);
StateProvider<double> headerHeight = StateProvider((ref) => 50);
StateProvider<double> eachLaneWidth = StateProvider((ref) => 500);
StateProvider<double> eachIdeaHight = StateProvider((ref) => 64); //48+8+8
StateProvider<double> lanesBodyCanvasHight = StateProvider((ref) => 500);
//タイトル行のスクロール用
StateProvider<double> canvasScrollDeltaX = StateProvider((ref) => 0);
StateProvider<double> canvasScrollDeltaY = StateProvider((ref) => 0);
Offset getWidgetGlobalPositionInInteractiveViewer(GlobalKey widgetKey, TransformationController transformationController, GlobalKey interactiveViewerKey) {
// 変換行列を取得
final matrix = transformationController.value;
// ウィジェットのRenderBoxを取得
final renderBox = widgetKey.currentContext?.findRenderObject() as RenderBox;
// ウィジェットのローカル座標を取得
final localOffset = renderBox.localToGlobal(Offset.zero);
// ウィジェットのグローバル座標を計算
final globalOffset = MatrixUtils.transformPoint(Matrix4.inverted(transformationController.value),localOffset);
return globalOffset;
}
Offset matrix4_transform_point(Matrix4 matrix, Offset point) {
final vector_math.Vector3 transformed = matrix * vector_math.Vector3(point.dx, point.dy, 0);
return Offset(transformed.x, transformed.y);
}
//関連付けの線を描画する
void relationLinePaint(Canvas canvas,TransformationController transformationController,CanvasZone widget, GlobalKey interactiveViewerKey) {
final paint = Paint()
..color = Colors.blue
..strokeWidth = 2;
if (widget.key.currentState == null) return;
if (widget.key.currentState!.lanes.isEmpty) return;
if (widget.key.currentState!.lanes[0].ideas.isEmpty) return;
if (widget.key.currentState!.lanes[0].key.currentContext == null) return;
if (widget.key.currentState!.relatedIdeasList.isNotEmpty) {
for (var relationSet in widget.key.currentState!.relatedIdeasList) {
Idea origin = relationSet.origin;
Idea related = relationSet.related;
print("[relationLinePaint] origin lane:${origin.parentLane?.index},idea:${origin.index} , related lane:${related.parentLane?.index},idea:${related.index}");
RenderBox lane0Box = widget.key.currentState!.lanes[0].key.currentContext?.findRenderObject() as RenderBox;
RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
RenderBox relatedIdeaBox = related.key.currentContext?.findRenderObject() as RenderBox;
RenderBox relatedIdeaTextFieldBox = related.textFieldKey.currentContext?.findRenderObject() as RenderBox;
/*
final paint1 = Paint()
..color = Colors.blue;
canvas.drawCircle(getWidgetGlobalPositionInInteractiveViewer(origin.textFieldKey, transformationController, interactiveViewerKey), 5, paint1);
*/
// double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );
double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );
double originTextFieldX = originIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
if (origin.parentLane!.index < related.parentLane!.index) { //Originが左側なら、Originの右側を起点とする
originTextFieldX += originIdeaTextFieldBox.size.width;
}
// canvas.drawCircle(Offset(originTextFieldX, originIdeaY) , 3, paint);
canvas.drawCircle(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY)) , 3, paint);
/*
final paint2 = Paint()
..color = Colors.orange;
Offset a = MatrixUtils.transformPoint(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY));
canvas.drawCircle(a , 3, paint2);
final paint3 = Paint()
..color = Colors.purpleAccent;
Offset b = matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY));
canvas.drawCircle(b , 5, paint3);
*/
// double relatedIdeaY = relatedIdeaBox.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy + (relatedIdeaBox.size.height / 2 );
double relatedIdeaY = relatedIdeaBox.localToGlobal(Offset.zero).dy - (relatedIdeaBox.size.height / 2 );
double relatedTextFieldX = relatedIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
if (origin.parentLane!.index > related.parentLane!.index) { //Originが右側なら、Relatedの右側を起点とする
relatedTextFieldX += relatedIdeaTextFieldBox.size.width;
}
if (origin.parentLane!.index < related.parentLane!.index && related.indentLevel >= 1) { //Relatedが右側にあり、インデントされている場合
relatedIdeaY += 5; //Related側のインデントの線と重なるのを避けるために少しずらす
}
// canvas.drawCircle(Offset(relatedTextFieldX, relatedIdeaY) , 3, paint);
// canvas.drawLine(Offset(originTextFieldX, originIdeaY), Offset(relatedTextFieldX, relatedIdeaY), paint);
canvas.drawCircle(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(relatedTextFieldX, relatedIdeaY)) , 3, paint);
canvas.drawLine(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY)), matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(relatedTextFieldX, relatedIdeaY)), paint);
}
}
}
void relationLinePaintForDebug(Canvas canvas, CanvasZone widget) {
//ツールバーの下:Offset(0,0) 又は canvasZone..RenderObject()のTop
//lanes[0]..RenderObject().localToGlobalのTopは、なぜかIdeasのTopの位置あたりになってしまう(タイトルより下)
//lanes[0].ideas[0]..RenderObject().localToGlobalのTopは、中途半端に2つめのIdeasのTopのあたりになってしまう
//とりあえずtitle分下にずらし、idea[0]のTopの位置を求めるには下記で行けそう、
//allIdeaTopY = idea0box.localToGlobal(Offset.zero).dy - lane0box.localToGlobal(Offset.zero).dy;
final paint = Paint()
..color = Colors.black;
canvas.drawCircle(const Offset(10,0), 5, paint);
paint.color = Colors.purple;
canvas.drawCircle( (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero), 5, paint);
if (widget.key.currentState == null) return;
if (widget.key.currentState!.lanes.isEmpty) return;
if (widget.key.currentState!.lanes[0].ideas.isEmpty) return;
RenderBox lane0Box = widget.key.currentState!.lanes[0].key.currentContext?.findRenderObject() as RenderBox;
RenderBox idea0Box = widget.key.currentState!.lanes[0].ideas[0].key.currentContext?.findRenderObject() as RenderBox;
RenderBox idea0TextFieldBox = widget.key.currentState!.lanes[0].ideas[0].textFieldKey.currentContext?.findRenderObject() as RenderBox;
paint.color = Colors.red;
canvas.drawCircle(lane0Box.localToGlobal(Offset.zero), 5, paint);
paint.color = Colors.green;
canvas.drawCircle(idea0Box.localToGlobal(Offset.zero), 10, paint);
paint.color = Colors.yellow;
double allIdeaTopY = idea0Box.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy;
double textFieldLeftX = idea0TextFieldBox.localToGlobal(Offset.zero).dx;
canvas.drawCircle(Offset(textFieldLeftX + idea0TextFieldBox.size.width, allIdeaTopY + (idea0Box.size.height / 2 )) , 5, paint);
}
void indentLinePaint(Canvas canvas,Size size,BuildContext context, int index, int indentLevel, double textFieldHeight, double boxSpacing, double indentWidth) {
// インデントの線を描画する
final paint = Paint()
..color = Colors.grey
..strokeWidth = 1;
var leftX = indentWidth / 2;
// 一行上のIdeaのindentLevelを取得する
int upperIndentLevel = 0;
int upperIndex = index - 1;
//描画:Canvasの起点は左側センター
if (indentLevel > 0 && index > 0 /*一番上の時はツリーを描画しない*/) {
//一番上の親が居ない場合の特別対応:自分の上のどこかに、indentLevelが小さい親がいるかチェック
bool cancelFlg = false; //描画しない特別な場合用
var i = index - 1;
while (i >= 0) {
if ((LaneBodyInheritedWidget.of(context)
.ideas[i].indentLevel) <
indentLevel) {
break;
}
i--;
}
if (i == -1) cancelFlg = true;
if (!cancelFlg) {
//縦の線:左側の基準
leftX = leftX + (indentWidth * (indentLevel - 1));
//縦の線:自分の領域内で上に引く
double startY =
0 - (textFieldHeight / 2) - (boxSpacing * 2);
//縦の線:自分の領域外でどのくらい上まで引くかを計算
upperIndentLevel = LaneBodyInheritedWidget.of(context)
.ideas[upperIndex].indentLevel;
if (indentLevel <= upperIndentLevel) {
while (indentLevel < upperIndentLevel && upperIndex >= 0) {
startY -= (textFieldHeight + boxSpacing * 2);
upperIndex--;
upperIndentLevel = LaneBodyInheritedWidget.of(context)
.ideas[upperIndex].indentLevel;
}
if (indentLevel <= upperIndentLevel) {
startY -= textFieldHeight / 2;
}
}
//縦の線
canvas.drawLine(Offset(leftX, 0), Offset(leftX, startY), paint);
//横の線
canvas.drawLine(Offset(leftX, size.height),
Offset(indentWidth * indentLevel, size.height), paint);
}
}
}
void findAndRepaintAffectedIdeas(BuildContext context, int myIndex, int myIndentLevel, int startIndex, int endIndex){
// Find the start index
for (int i = myIndex - 1; i >= 0; i--) {
if (LaneBodyInheritedWidget.of(context)
.ideas[i].indentLevel == myIndentLevel - 1) {
startIndex = i;
break;
}
}
// Find the end index
for (int i = myIndex + 1;
i < LaneBodyInheritedWidget.of(context).ideas.length;
i++) {
if ((LaneBodyInheritedWidget.of(context)
.ideas[i].indentLevel) >= myIndentLevel) {
endIndex = i;
break;
}
}
LaneBodyInheritedWidget.of(context).redrawAffectedIdeas(startIndex, endIndex);
CanvasZoneInheritedWidget.of(context).counterForReaint.value++;
}
Future<String> get _defaultFileName async {
final directory = await getApplicationDocumentsDirectory();
return '${directory.path}/default.json';
}
Future<void> saveData({String? fileName, required CanvasZoneState? state}) async {
List<Map<String, dynamic>> lanesJson = [];
fileName ??= await _defaultFileName;
if(state == null) return;
for (var lane in state.lanes ) {
List<Map<String, dynamic>> ideasJson = [];
for (var idea in lane.ideas) {
ideasJson.add({
'index': idea.index,
'indentLevel': idea.indentLevel,
'content': idea.key.currentState?.textController.text ?? '',
});
}
lanesJson.add({
'key': lane.key.toString(),
'index': lane.index,
'title': state.laneHeaders[lane.index].key.currentState?.titleController.text ?? '',
'ideas': ideasJson,
});
}
List<Map<String, dynamic>> relatedIdeasJson = [];
state.relatedIdeasList.forEach((relationSet) {
relatedIdeasJson.add({
'origin_lane_index': relationSet.origin.parentLane?.index,
'origin_idea_key': relationSet.origin.key.toString(),
'origin_idea_index': relationSet.origin.index,
'related_lane_index': relationSet.related.parentLane?.index,
'related_idea_key': relationSet.related.key.toString(),
'related_idea_index': relationSet.related.index,
});
});
String jsonString = jsonEncode({'lanes': lanesJson, 'relatedIdeas': relatedIdeasJson});
await File(fileName).writeAsString(jsonString);
}
Future<void> loadData({String? fileName, required CanvasZoneState? state}) async {
fileName ??= await _defaultFileName;
final file = File(fileName);
if (await file.exists() == false) {
print('No such files:{$fileName}');
return;
}
String jsonString = await file.readAsString();
Map<String, dynamic> jsonMap = jsonDecode(jsonString);
if(state == null) return;
List<LaneBody> loadedLanes = [];
List<LaneHeader> loadedLaneHeaders = [];
for (var laneJson in jsonMap['lanes']) {
List<Idea> loadedIdeas = [];
var newLane = LaneBody(
index: laneJson['index'] ?? 0,
title: laneJson['title'] ?? '',
ideas: loadedIdeas,
);
var newLaneHeader = LaneHeader(index: laneJson['index'] ?? 0, relatedLane: newLane);
newLaneHeader.title = laneJson['title'] ?? '';
loadedLaneHeaders.add(newLaneHeader);
for (var ideaJson in laneJson['ideas']) {
loadedIdeas.add(Idea(
index: ideaJson['index'],
indentLevel: ideaJson['indentLevel'],
parentLane: newLane,
));
}
loadedLanes.add(newLane);
}
for (int i = 0; i < loadedLanes.length; i++) {
for (int j = 0; j < loadedLanes[i].ideas.length; j++) {
loadedLanes[i].ideas[j].content = jsonMap['lanes'][i]['ideas'][j]['content'];
}
}
state.lanes = loadedLanes;
state.laneHeaders = loadedLaneHeaders;
state.relatedIdeasList.clear();
if (jsonMap['relatedIdeas'] != null) {
for (var relatedJson in jsonMap['relatedIdeas']) {
var originLaneIndex = relatedJson['origin_lane_index'];
var originIdeaIndex = relatedJson['origin_idea_index'];
var relatedLaneIndex = relatedJson['related_lane_index'];
var relatedIdeaIndex = relatedJson['related_idea_index'];
IdeasRelation relationSet = IdeasRelation(
origin: loadedLanes[originLaneIndex].ideas[originIdeaIndex], related: loadedLanes[relatedLaneIndex].ideas[relatedIdeaIndex]);
state.relatedIdeasList.add(relationSet);
print("[loadData] origin lane:$originLaneIndex,idea:$originIdeaIndex, related lane:$relatedLaneIndex,idea:$relatedIdeaIndex");
loadedLanes[originLaneIndex].ideas[originIdeaIndex].relatedIdeas.add(loadedLanes[relatedLaneIndex].ideas[relatedIdeaIndex]);
}
}
}
※本記事は過去の記録をもとに作成し、必要に応じて加筆・補足しています
テキストベースの思考整理ツール「アイディア・レーン」最新版はこちら。
コメント