Kakao 이미지 검색 API를 이용하여 검색한 이미지 목록을 보여주고 즐겨찾기 하는 앱 예제입니다.
Flutter로 작성한 첫 코드이기 때문에 완성도가 보장되지 않습니다😅
검색과 즐겨찾기 페이지를 만들고 이를 TabBar로 네비게이션합니다.
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
TabController? tabController;
void initState() {
super.initState();
tabController = TabController(length: 2, vsync: this);
}
void dispose() {
tabController!.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
body: TabBarView(
controller: tabController,
children: <Widget>[SearchPage(), FavoritePage()],
),
bottomNavigationBar: TabBar(tabs: const <Tab>[
Tab(icon: Icon(Icons.search, color: Colors.white)),
Tab(icon: Icon(Icons.favorite_outline, color: Colors.white)),
],
controller: tabController,
indicatorColor: Colors.white,
indicatorWeight: 3,
),
);
}
}
검색창 UI를 구현합니다.
class SearchPage extends StatefulWidget {
State<StatefulWidget> createState() {
return _SearchPageState();
}
}
class _SearchPageState extends State<SearchPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Center(child: _buildSearchBar()), backgroundColor: Colors.black,),
body: Center(
),
);
}
/// 검색창을 생성합니다
Widget _buildSearchBar() {
return SearchBar(
trailing: const [Icon(Icons.search)],
constraints: const BoxConstraints(maxWidth: 300, minHeight: 36),
hintText: "검색어를 입력하세요",
textInputAction: TextInputAction.done,
onChanged: (text) {
debugPrint('input text: $text');
},
onSubmitted: (text) {
if (text.isEmpty) {
// 검색어 미입력 시 스낵바로 알림
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("검색어를 입력하세요"),
duration: Duration(seconds: 2),
));
}
},
);
}
}
기본 UI는 준비되었으니 이미지 검색를 구현합니다.
SearchRepository는 API를 통해 이미지를 검색하고 결과를 반환하는 기능을 제공합니다.
class SearchRepository {
/// 검색어에 해당하는 이미지를 조회합니다
Future<List<ImageModel>> getImageList(String query, int page) async {
final url = "https://dapi.kakao.com/v2/search/image?target=title&page=$page&query=$query";
final response = await http.get(
Uri.parse(url),
headers: {"Authorization": "KakaoAK $REST_API_KEY"}
);
return jsonDecode(response.body)['documents']
.map<ImageModel>((json) => ImageModel.fromJson(json))
.toList();
}
}
SearchViewModel은 검색 기능을 관리하고 뷰에 변경 사항을 알리는 역할을 수행합니다.
class SearchViewModel extends ChangeNotifier {
final SearchRepository _repository;
final List<ImageModel> _imageList = [];
String _inputText = '';
int _page = 1;
bool _isLoading = false;
SearchViewModel(this._repository);
List<ImageModel> get imageList => _imageList;
String get inputText => _inputText;
bool get isLoading => _isLoading;
void updateInputText(String text) {
_inputText = text;
}
void nextPage() {
_page++;
}
/// 검색 초기화
void resetSearch() {
_inputText = '';
resetImageList();
}
/// 검색 시작
void search() {
resetImageList();
getImageList();
}
/// 이미지 리스트 초기화
void resetImageList() {
_imageList.clear();
_page = 1;
notifyListeners();
}
/// 이미지 조회
Future<void> getImageList() async {
if (_isLoading) return;
_isLoading = true;
List<ImageModel> images = await _repository.getImageList(_inputText, _page);
_imageList.addAll(images);
_isLoading = false;
notifyListeners();
}
}
SearchPage는 검색 페이지의 뷰를 담당합니다
class SearchPage extends StatefulWidget {
State<StatefulWidget> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
late ScrollController _scrollController;
void initState() {
super.initState();
// 무한 스크롤
_scrollController = ScrollController();
_scrollController.addListener(() {
final viewModel = Provider.of<SearchViewModel>(context);
if (viewModel.isLoading) return;
if (_scrollController!.offset >= _scrollController!.position.maxScrollExtent &&
!_scrollController!.position.outOfRange) {
viewModel.nextPage();
viewModel.getImageList();
}
});
}
dispose() {
_scrollController.dispose();
super.dispose();
}
/// 이미지 클릭
onTapImage(ImageModel image) {
// 상세 페이지로 이동
Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailPage(image: image)));
}
Widget build(BuildContext context) {
final viewModel = Provider.of<SearchViewModel>(context);
return Scaffold(
appBar: AppBar(
title: Center(child: _buildSearchBar()),
backgroundColor: Colors.black,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: MasonryGridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
controller: _scrollController,
itemCount: viewModel.imageList.length,
itemBuilder: (BuildContext context, int index) {
final image = viewModel.imageList[index];
return ImageCell(
onTap: () => onTapImage(image),
image: image,
);
},
),
),
),
);
}
/// 검색창을 생성합니다
Widget _buildSearchBar() {
final viewModel = Provider.of<SearchViewModel>(context);
return SearchBar(
trailing: const [Icon(Icons.search)],
constraints: const BoxConstraints(maxWidth: 300, minHeight: 36),
hintText: "검색어를 입력하세요",
textInputAction: TextInputAction.done,
onChanged: (text) {
viewModel.updateInputText(text);
},
onSubmitted: (text) {
if (viewModel.inputText.isNotEmpty) {
viewModel.search();
return;
}
// 검색어 미입력 시 스낵바로 알림
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("검색어를 입력하세요"),
duration: Duration(seconds: 2),
));
},
);
}
}
완성된 검색 결과 화면
즐겨찾기 기능을 추가합니다.
FavoriteRepository는 즐겨찾기 이미지를 로컬 저장소에 저장하고 로드하는 기능을 제공합니다.
class FavoriteRepository {
late SharedPreferences pref;
late List<ImageModel> _favoriteImageList;
FavoriteRepository() {
_favoriteImageList = List.empty(growable: true);
_load();
}
List<ImageModel> get favoriteImageList => _favoriteImageList;
/// 즐겨찾기 데이터를 불러옵니다
void _load() async {
pref = await SharedPreferences.getInstance();
final json = pref.getString(FAVORITE_DATA_KEY);
if (json != null && json.isNotEmpty) {
_favoriteImageList = jsonDecode(json)['favorite_list']
.map<ImageModel>((json) => ImageModel.fromJson(json))
.toList();
}
}
/// 즐겨찾기 이미지를 설정합니다
void setFavoriteImage(ImageModel image, bool isFavorite) async {
if (isFavorite) {
_favoriteImageList.add(image);
} else {
_favoriteImageList.removeWhere((it) => it.imageUrl == image.imageUrl);
}
Map<String, dynamic> jsonMap = {
'favorite_list': _favoriteImageList.map((image) => image.toJson()).toList(),
};
pref.setString(FAVORITE_DATA_KEY, jsonEncode(jsonMap));
}
bool isFavoriteImage(ImageModel image) {
return _favoriteImageList.any((it) => it.imageUrl == image.imageUrl);
}
}
FavoriteViewModel은 즐겨찾기 이미지를 관리하고 뷰에 변경 사항을 알리는 역할을 수행합니다.
class FavoriteViewModel extends ChangeNotifier {
final FavoriteRepository _repository;
FavoriteViewModel(this._repository);
List<ImageModel> get favoriteImages => _repository.favoriteImageList;
void toggleFavoriteStatus(ImageModel image) {
_repository.setFavoriteImage(image, !isFavoriteImage(image));
notifyListeners();
}
bool isFavoriteImage(ImageModel image) {
return _repository.isFavoriteImage(image);
}
}
FavoritePage는 즐겨찾기 페이지의 뷰를 담당합니다
class FavoritePage extends StatefulWidget {
State<StatefulWidget> createState() => _FavoritePageState();
}
class _FavoritePageState extends State<FavoritePage> {
/// 이미지 클릭
onTapImage(ImageModel image) {
// 상세 페이지로 이동
Navigator.of(context).push(MaterialPageRoute(builder: (context) => DetailPage(image: image)));
}
Widget build(BuildContext context) {
final viewModel = Provider.of<FavoriteViewModel>(context);
return Scaffold(
appBar: AppBar(title: const Center(child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite, color: Colors.pink,),
Padding(padding: EdgeInsets.only(left: 8)),
Text("Favorite"),
],
),), backgroundColor: Colors.black, foregroundColor: Colors.white,),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: MasonryGridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
itemCount: viewModel.favoriteImages.length,
itemBuilder: (BuildContext context, int index) {
final image = viewModel.favoriteImages[index];
return ImageCell(
onTap: () => onTapImage(image),
image: image,
);
},
),
),
),
);
}
}
완성된 즐겨찾기 화면
개발을 마치고 테스트를 하던 중 페이지 전환 시 리렌더링되는 현상이 거슬려 TabBarView를 IndexedStack으로 변경했습니다.
추후에 유사한 UX를 구현하게되면 TabBarView, IndexedStack, PageView를 비교하여 보다 적합한 위젯을 선정해야 할 것 같습니다.
완성된 전체 소스코드는 GitHub에서 확인할 수 있습니다.