お借りしたパッケージ
実装イメージ
- キーボードの上下で予測を選択できるように改修
import
パッケージ追加
pubspec.yaml
dependencies:
flutter:
sdk: flutter
dio:
rxdart:
async:
flutter_riverpod:
# freezed
freezed_annotation:
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints:
# freezed
freezed:
build_runner:
json_serializable:
Client
クライアントクラスを作成
google_places_api.dart
import 'dart:convert';
import 'package:auto_complete_sample/models/place_details/place_details.dart';
import 'package:auto_complete_sample/models/prediction/prediction.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
class GooglePlacesApi {
GooglePlacesApi() : _dio = Dio();
final Dio _dio;
final _apiUrl = 'https://places.googleapis.com/v1/places';
Future<PlacesAutocompleteResponse?> getSuggestionsForInput({
required String input,
required String googleAPIKey,
List<String> countries = const [],
String? sessionToken,
String proxyUrl = '',
String? languageCode,
}) async {
final prefix = proxyUrl;
String url = "$prefix$_apiUrl:autocomplete";
Map<String, dynamic> requestBody = {
"input": input,
};
if (countries.isNotEmpty) {
requestBody["includedRegionCodes"] = countries;
requestBody["languageCode"] = languageCode;
}
if (sessionToken != null) {
requestBody["sessionToken"] = sessionToken;
}
Options options = Options(
headers: {
"X-Goog-Api-Key": googleAPIKey,
"X-Goog-FieldMask": "*",
},
);
try {
final response =
await _dio.post(url, options: options, data: jsonEncode(requestBody));
final subscriptionResponse =
PlacesAutocompleteResponse.fromJson(response.data);
return subscriptionResponse;
} on DioException catch (e) {
if (e.response != null) {
debugPrint(
'GooglePlacesApi.getSuggestionsForInput: DioException [${e.type}]: ${e.message}');
debugPrint('Response data: ${e.response?.data}');
} else {
debugPrint(
'GooglePlacesApi.getSuggestionsForInput: DioException [${e.type}]: ${e.message}');
}
return null;
} catch (e) {
debugPrint('GooglePlacesApi.getSuggestionsForInput: ${e.toString()}');
return null;
}
}
Future<Prediction?> fetchCoordinatesForPrediction({
required Prediction prediction,
required String googleAPIKey,
String proxyUrl = '',
String? sessionToken,
}) async {
try {
final Uri uri =
Uri.parse('$proxyUrl$_apiUrl/${prediction.placeId}').replace(
queryParameters: {
"fields": "*",
"key": googleAPIKey,
if (sessionToken != null) "sessionToken": sessionToken,
},
);
final Response response = await _dio.get(uri.toString());
if (response.data != null && response.statusCode == 200) {
final placeDetails = PlaceDetails.fromJson(response.data);
prediction.lat =
placeDetails.result?.geometry?.location?.lat?.toString();
prediction.lng =
placeDetails.result?.geometry?.location?.lng?.toString();
return prediction;
} else {
debugPrint('Unexpected response: ${response.statusCode}');
}
} on DioException catch (e) {
debugPrint(
'Error in fetchCoordinatesForPrediction: ${e.message}, Type: ${e.type}');
if (e.response != null) {
debugPrint('Response data: ${e.response?.data}');
}
} catch (e) {
debugPrint('Unexpected error in fetchCoordinatesForPrediction: $e');
}
return null;
}
}
model
- freezedで作成に変更
place_details.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'place_details.freezed.dart';
part 'place_details.g.dart';
@freezed
class PlaceDetails with _$PlaceDetails {
const factory PlaceDetails({
Result? result,
String? status,
}) = _PlaceDetails;
factory PlaceDetails.fromJson(Map<String, dynamic> json) =>
_$PlaceDetailsFromJson(json);
}
@freezed
class Result with _$Result {
const factory Result({
@JsonKey(name: 'addressComponents')
List<AddressComponents>? addressComponents,
@JsonKey(name: 'adrFormatAddress') String? adrAddress,
@JsonKey(name: 'formattedAddress') String? formattedAddress,
@JsonKey(name: 'location') Geometry? geometry,
@JsonKey(name: 'iconMaskBaseUri') String? icon,
String? name,
List<Photos>? photos,
@JsonKey(name: 'placeId') String? placeId,
String? reference,
String? scope,
List<String>? types,
@JsonKey(name: 'googleMapsUri') String? url,
@JsonKey(name: 'utcOffsetMinutes') int? utcOffset,
String? vicinity,
@JsonKey(name: 'websiteUri') String? website,
}) = _Result;
factory Result.fromJson(Map<String, dynamic> json) => _$ResultFromJson(json);
}
@freezed
class AddressComponents with _$AddressComponents {
const factory AddressComponents({
@JsonKey(name: 'long_name') String? longName,
@JsonKey(name: 'short_name') String? shortName,
List<String>? types,
}) = _AddressComponents;
factory AddressComponents.fromJson(Map<String, dynamic> json) =>
_$AddressComponentsFromJson(json);
}
@freezed
class Photos with _$Photos {
const factory Photos({
@JsonKey(name: 'heightPx') int? height,
@JsonKey(name: 'authorAttributions') List<String>? htmlAttributions,
@JsonKey(name: 'name') String? photoReference,
@JsonKey(name: 'widthPx') int? width,
}) = _Photos;
factory Photos.fromJson(Map<String, dynamic> json) => _$PhotosFromJson(json);
}
@freezed
class Geometry with _$Geometry {
const factory Geometry({
Location? location,
Viewport? viewport,
}) = _Geometry;
factory Geometry.fromJson(Map<String, dynamic> json) =>
_$GeometryFromJson(json);
}
@freezed
class Location with _$Location {
const factory Location({
@JsonKey(name: 'latitude') double? lat,
@JsonKey(name: 'longitude') double? lng,
}) = _Location;
factory Location.fromJson(Map<String, dynamic> json) =>
_$LocationFromJson(json);
}
@freezed
class Viewport with _$Viewport {
const factory Viewport({
Location? northeast,
Location? southwest,
}) = _Viewport;
factory Viewport.fromJson(Map<String, dynamic> json) =>
_$ViewportFromJson(json);
}
places_autocomplete_response.dart
class PlacesAutocompleteResponse {
List<Prediction>? predictions;
String? status;
PlacesAutocompleteResponse({
this.predictions,
this.status,
});
PlacesAutocompleteResponse.fromJson(Map<String, dynamic> json) {
if (json['suggestions'] != null && json['suggestions'].length > 0) {
predictions = [];
json['suggestions'].forEach((v) {
if (v['placePrediction'] != null) {
predictions!.add(Prediction.fromJson(v['placePrediction']));
}
});
}
}
}
class Prediction {
String? description;
String? id;
List<MatchedSubstrings>? matchedSubstrings;
String? placeId;
String? reference;
StructuredFormatting? structuredFormatting;
List<Terms>? terms;
List<String>? types;
String? lat;
String? lng;
Prediction({
this.description,
this.id,
this.matchedSubstrings,
this.placeId,
this.reference,
this.structuredFormatting,
this.terms,
this.types,
this.lat,
this.lng,
});
Prediction.fromJson(Map<String, dynamic> json) {
placeId = json['placeId'];
description = json['text'] != null ? json['text']['text'] : null;
structuredFormatting = json['structuredFormat'] != null
? StructuredFormatting.fromJson(json['structuredFormat'])
: null;
types = json['types'].cast<String>();
lat = json['lat'];
lng = json['lng'];
}
}
class MatchedSubstrings {
int? length;
int? offset;
MatchedSubstrings({this.length, this.offset});
MatchedSubstrings.fromJson(Map<String, dynamic> json) {
length = json['length'];
offset = json['offset'];
}
}
class StructuredFormatting {
String? mainText;
String? secondaryText;
StructuredFormatting({this.mainText, this.secondaryText});
StructuredFormatting.fromJson(Map<String, dynamic> json) {
mainText = json['mainText'] != null ? json['mainText']['text'] : null;
secondaryText =
json['secondaryText'] != null ? json['secondaryText']['text'] : null;
}
}
class Terms {
int? offset;
String? value;
Terms({this.offset, this.value});
Terms.fromJson(Map<String, dynamic> json) {
offset = json['offset'];
value = json['value'];
}
}
カスタムWidget
widget.dart
import 'dart:async';
import 'dart:io';
import 'package:async/async.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:rxdart/rxdart.dart';
import 'data/google_places_api.dart';
import 'models/prediction/prediction.dart';
class GooglePlacesAutoCompleteTextFormField extends StatefulWidget {
const GooglePlacesAutoCompleteTextFormField({
required this.googleAPIKey,
this.textEditingController,
this.debounceTime = 600,
this.onSuggestionClicked,
this.fetchCoordinates = true,
this.countries = const [],
this.onPlaceDetailsWithCoordinatesReceived,
this.predictionsStyle,
this.overlayContainerBuilder,
this.proxyURL,
this.minInputLength = 0,
this.sessionToken,
this.initialValue,
this.fetchSuggestionsForInitialValue = false,
this.focusNode,
this.decoration,
this.keyboardType,
this.textCapitalization = TextCapitalization.none,
this.textInputAction,
this.style,
this.strutStyle,
this.textDirection,
this.textAlign = TextAlign.start,
this.textAlignVertical,
this.autofocus = false,
this.readOnly = false,
this.showCursor,
this.obscuringCharacter = '•',
this.obscureText = false,
this.autocorrect = true,
this.smartDashesType,
this.smartQuotesType,
this.enableSuggestions = true,
this.maxLengthEnforcement,
this.maxLines,
this.minLines,
this.expands = false,
this.maxLength,
this.onChanged,
this.onTap,
this.onTapOutside,
this.onEditingComplete,
this.onFieldSubmitted,
this.inputFormatters,
this.enabled,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20.0),
this.enableInteractiveSelection,
this.selectionControls,
this.buildCounter,
this.scrollPhysics,
this.autofillHints,
this.autovalidateMode,
this.scrollController,
this.enableIMEPersonalizedLearning = true,
this.mouseCursor,
this.contextMenuBuilder,
this.validator,
this.maxHeight = 200,
this.languageCode,
this.onError,
super.key,
});
final String? initialValue;
final bool fetchSuggestionsForInitialValue;
final FocusNode? focusNode;
final TextEditingController? textEditingController;
final void Function(Prediction prediction)? onSuggestionClicked;
final void Function(Prediction prediction)?
onPlaceDetailsWithCoordinatesReceived;
final bool fetchCoordinates;
final String googleAPIKey;
final int debounceTime;
final List<String>? countries;
final String? languageCode;
final TextStyle? predictionsStyle;
final Widget Function(Widget overlayChild)? overlayContainerBuilder;
final String? proxyURL;
final int minInputLength;
final String? sessionToken;
final double maxHeight;
final Function? onError;
final InputDecoration? decoration;
final TextInputType? keyboardType;
final TextCapitalization textCapitalization;
final TextInputAction? textInputAction;
final TextStyle? style;
final StrutStyle? strutStyle;
final TextDirection? textDirection;
final TextAlign textAlign;
final TextAlignVertical? textAlignVertical;
final bool autofocus;
final bool readOnly;
final bool? showCursor;
final String obscuringCharacter;
final bool obscureText;
final bool autocorrect;
final SmartDashesType? smartDashesType;
final SmartQuotesType? smartQuotesType;
final bool enableSuggestions;
final MaxLengthEnforcement? maxLengthEnforcement;
final int? maxLines;
final int? minLines;
final bool expands;
final int? maxLength;
final ValueChanged<String>? onChanged;
final GestureTapCallback? onTap;
final TapRegionCallback? onTapOutside;
final VoidCallback? onEditingComplete;
final ValueChanged<String>? onFieldSubmitted;
final List<TextInputFormatter>? inputFormatters;
final bool? enabled;
final double cursorWidth;
final double? cursorHeight;
final Radius? cursorRadius;
final Color? cursorColor;
final Brightness? keyboardAppearance;
final EdgeInsets scrollPadding;
final bool? enableInteractiveSelection;
final TextSelectionControls? selectionControls;
final InputCounterWidgetBuilder? buildCounter;
final ScrollPhysics? scrollPhysics;
final Iterable<String>? autofillHints;
final AutovalidateMode? autovalidateMode;
final ScrollController? scrollController;
final bool enableIMEPersonalizedLearning;
final MouseCursor? mouseCursor;
final EditableTextContextMenuBuilder? contextMenuBuilder;
final String? Function(String?)? validator;
@override
State<GooglePlacesAutoCompleteTextFormField> createState() =>
_GooglePlacesAutoCompleteTextFormFieldState();
}
class _GooglePlacesAutoCompleteTextFormFieldState
extends State<GooglePlacesAutoCompleteTextFormField> {
final subject = PublishSubject<String>();
late GooglePlacesApi _api;
OverlayEntry? _overlayEntry;
List<Prediction> allPredictions = [];
final LayerLink _layerLink = LayerLink();
late FocusNode _focus;
late StreamSubscription<String> subscription;
CancelableOperation? cancelableOperation;
int _selectedIndex = -1;
@override
void dispose() {
subscription.cancel();
subject.close();
_focus.dispose();
if (cancelableOperation != null) {
cancelableOperation?.cancel();
}
super.dispose();
}
@override
void initState() {
_api = GooglePlacesApi();
subscription = subject.stream
.distinct()
.debounceTime(Duration(milliseconds: widget.debounceTime))
.listen(textChanged);
_focus = widget.focusNode ?? FocusNode();
_focus.addListener(() {
if (!_focus.hasFocus && allPredictions.isNotEmpty) {
// インデックスが範囲外にならないようにチェック
if (_selectedIndex >= 0 && _selectedIndex < allPredictions.length) {
final selectedPrediction = allPredictions[_selectedIndex];
widget.textEditingController?.text = selectedPrediction.description!;
widget.onChanged?.call(selectedPrediction.description!);
}
removeOverlay();
_selectedIndex = -1;
} else if (!_focus.hasFocus) {
removeOverlay();
_selectedIndex = -1;
}
});
if (!kIsWeb && !Platform.isMacOS) {
_focus.addListener(() {
if (!_focus.hasFocus) {
removeOverlay();
}
});
}
if (widget.initialValue != null && widget.fetchSuggestionsForInitialValue) {
subject.add(widget.initialValue!);
}
if (widget.initialValue != null && widget.textEditingController != null) {
widget.textEditingController!.text = widget.initialValue!;
}
super.initState();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (KeyEvent event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
// 予測リストが空でない場合のみインデックスを更新
if (allPredictions.isNotEmpty) {
setState(() {
_selectedIndex = (_selectedIndex + 1) % allPredictions.length;
});
_overlayEntry?.markNeedsBuild();
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
if (allPredictions.isNotEmpty) {
setState(() {
_selectedIndex =
(_selectedIndex - 1 + allPredictions.length) %
allPredictions.length;
});
_overlayEntry?.markNeedsBuild();
}
} else if (event.logicalKey == LogicalKeyboardKey.enter) {
if (_selectedIndex != -1 &&
_selectedIndex < allPredictions.length &&
_overlayEntry != null) {
final selectedPrediction = allPredictions[_selectedIndex];
widget.onSuggestionClicked?.call(selectedPrediction);
if (widget.fetchCoordinates) {
getPlaceDetailsFromPlaceId(selectedPrediction);
}
removeOverlay();
} else if (_overlayEntry == null || allPredictions.isEmpty) {
widget.onFieldSubmitted
?.call(widget.textEditingController?.text ?? '');
}
}
}
},
child: TextFormField(
onFieldSubmitted: (value) {
if (_overlayEntry == null || allPredictions.isEmpty) {
widget.onFieldSubmitted?.call(value);
}
},
controller: widget.textEditingController,
initialValue:
widget.textEditingController != null ? null : widget.initialValue,
focusNode: _focus,
decoration: widget.decoration,
keyboardType: widget.keyboardType,
textCapitalization: widget.textCapitalization,
textInputAction: widget.textInputAction,
style: widget.style,
strutStyle: widget.strutStyle,
textDirection: widget.textDirection,
textAlign: widget.textAlign,
textAlignVertical: widget.textAlignVertical,
autofocus: widget.autofocus,
readOnly: widget.readOnly,
showCursor: widget.showCursor,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
smartDashesType: widget.smartDashesType,
smartQuotesType: widget.smartQuotesType,
enableSuggestions: widget.enableSuggestions,
maxLengthEnforcement: widget.maxLengthEnforcement,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
maxLength: widget.maxLength,
onChanged: (string) {
widget.onChanged?.call(string);
subject.add(string);
},
onTap: widget.onTap,
onTapOutside: widget.onTapOutside,
onEditingComplete: widget.onEditingComplete,
inputFormatters: widget.inputFormatters,
enabled: widget.enabled,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorColor: widget.cursorColor,
keyboardAppearance: widget.keyboardAppearance,
scrollPadding: widget.scrollPadding,
enableInteractiveSelection: widget.enableInteractiveSelection,
selectionControls: widget.selectionControls,
buildCounter: widget.buildCounter,
scrollPhysics: widget.scrollPhysics,
autofillHints: widget.autofillHints,
autovalidateMode: widget.autovalidateMode,
scrollController: widget.scrollController,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
mouseCursor: widget.mouseCursor,
contextMenuBuilder: widget.contextMenuBuilder,
validator: widget.validator,
),
),
);
}
Future<void> getLocation(String text) async {
if (text.isEmpty || text.length < widget.minInputLength) {
allPredictions.clear();
_overlayEntry?.remove();
_overlayEntry?.dispose();
return;
}
try {
final result = await _api.getSuggestionsForInput(
input: text,
googleAPIKey: widget.googleAPIKey,
countries: widget.countries ?? [],
sessionToken: widget.sessionToken,
proxyUrl: widget.proxyURL ?? "",
languageCode: widget.languageCode,
);
if (result == null) return;
final predictions = result.predictions;
if (predictions == null || predictions.isEmpty) return;
allPredictions.clear();
allPredictions.addAll(predictions);
} catch (e) {
debugPrint('getLocation: ${e.toString()}');
if (e is DioException) {
widget.onError?.call(e.response);
} else {
widget.onError?.call(e);
}
}
}
Future<void> getPlaceDetailsFromPlaceId(Prediction prediction) async {
final predictionWithCoordinates = await _api.fetchCoordinatesForPrediction(
prediction: prediction,
googleAPIKey: widget.googleAPIKey,
proxyUrl: widget.proxyURL ?? "",
sessionToken: widget.sessionToken,
);
if (predictionWithCoordinates == null) return;
widget.onPlaceDetailsWithCoordinatesReceived
?.call(predictionWithCoordinates);
}
Future<dynamic> fromCancelable(Future<dynamic> future) async {
cancelableOperation =
CancelableOperation.fromFuture(future, onCancel: () {});
return cancelableOperation?.value;
}
Future<void> textChanged(String text) async {
final overlay = Overlay.of(context);
fromCancelable(getLocation(text)).then(
(_) {
try {
_overlayEntry?.remove();
} catch (_) {}
_overlayEntry = null;
_overlayEntry = _createOverlayEntry();
overlay.insert(_overlayEntry!);
},
);
}
OverlayEntry? _createOverlayEntry() {
if (context.findRenderObject() != null) {
final renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
var offset = renderBox.localToGlobal(Offset.zero);
return OverlayEntry(
builder: (context) => Positioned(
left: offset.dx,
top: size.height + offset.dy,
width: size.width,
child: CompositedTransformFollower(
showWhenUnlinked: false,
link: _layerLink,
offset: Offset(0.0, size.height + 5.0),
child: widget.overlayContainerBuilder?.call(_overlayChild) ??
Material(
elevation: 1.0,
child: Container(
constraints: BoxConstraints(
maxHeight: widget.maxHeight,
),
child: _overlayChild,
),
),
),
),
);
}
return null;
}
Widget get _overlayChild {
return ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: allPredictions.length,
itemBuilder: (BuildContext context, int index) => InkWell(
onTap: () {
if (index < allPredictions.length) {
widget.onSuggestionClicked?.call(allPredictions[index]);
if (widget.fetchCoordinates) {
getPlaceDetailsFromPlaceId(allPredictions[index]);
}
removeOverlay();
}
},
child: Builder(
builder: (BuildContext context) {
final bool highlight = index == _selectedIndex;
if (highlight) {
SchedulerBinding.instance.addPostFrameCallback((_) {
Scrollable.ensureVisible(context, alignment: 0.5);
});
}
return Container(
color: highlight ? Theme.of(context).focusColor : null,
padding: const EdgeInsets.all(10),
child: Text(
allPredictions[index].description!,
style: widget.predictionsStyle ?? widget.style,
),
);
},
),
),
);
}
void removeOverlay() {
allPredictions.clear();
try {
_overlayEntry?.remove();
} catch (_) {}
_overlayEntry?.dispose();
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
_overlayEntry!.markNeedsBuild();
}
}
View
import 'package:auto_complete_sample/google_places_auto_complete_text_form_field.dart';
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: GooglePlacesAutoCompleteTextFormField(
textInputAction: TextInputAction.done,
textEditingController: _controller,
googleAPIKey: 'YOUR-API-KEY',
debounceTime: 400,
countries: const ["jp"],
fetchCoordinates: true,
onSuggestionClicked: (prediction) {
_controller.text = prediction.description!;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: prediction.description!.length));
},
),
),
],
),
),
);
}
}
感想
privateな変数がなければ、継承してやろうと思ったが出来なさそうなのでコピペベースで作成になってしまった。本当はForkしてPR出したい
参考