Framework/Flutter

[Flutter]홈 위젯 만들기 iOS편 - 2) 위젯 커스텀하기

  • -
반응형

지난 글에 이어 생성된 위젯을 변경해보겠습니다.

결과물을 먼저 보면 가계부의 지출액과 수입/지출 추가로 이어지는 버튼을 추가해봤습니다.

 


1. 위젯 커스텀

Xcode를 실행하여 위젯 코드를 수정해봅니다.

 

처음 코드를 보면 SimpleEntry가 존재하는데, 여기에 데이터를 추가로 받기 위해 content 파라미터를 추가합니다.

struct SimpleEntry: TimelineEntry {
    let date: Date
    let emoji: String
    let content: String
}

 

SimpleEntry를 호출하는 코드에 content 파라미터를 추가합니다.

TimelineProvider안에 getSnapshot과 getTimeline은 다음과 같은 상황에 사용됩니다.

getSnapshot: 위젯 추가시, 위젯 리스트에 표시될 때 실행되는 함수

getTimeline: 위젯 추가후 홈 화면에 표시될 때 실행되는 함수

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), emoji: "😀", content: "1")
    }

    // 위젯 추가시 보이는 화면, 위젯 리스트 선택
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), emoji: "😀", content: "0")
        completion(entry)
    }

    // 메인에 조회되는 위젯
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        let userDefault = UserDefaults(suiteName: "group.woori.homeWidget")
        let amount = userDefault?.string(forKey: "WooriWidgetKey") ?? "0"

        let entryDate = Date()
        let entry = SimpleEntry(date: entryDate, emoji: "😀", content: amount)
        entries.append(entry)

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

 

[코드설명]

let userDefault = UserDefaults(suiteName: "group.woori.homeWidget")
let amount = userDefault?.string(forKey: "WooriWidgetKey") ?? "0"

 

  • App Group(group.woori.homeWidget)을 사용하여 앱과 위젯 간 데이터 공유를 가능하게 합니다.
  • WooriWidgetKey는 Flutter에서 저장한 금액 문자열의 Key입니다. 이 값을 통해 가계부 지출액을 받아와 위젯에 보여줍니다.

 

VStack, HStack, Text 등을 활용하여 기본 위젯을 꾸몄습니다.

앱로고를 추가하기 위해 Image를 사용했고, Assets에 이미지를 추가하고 이미지명을 호출하면 됩니다.

struct WooriWidgetEntryView : View {
    var entry: Provider.Entry
    let buttonColor = Color.white.opacity(0.18) // 원하는 투명도에 맞게 조절
    
    var body: some View {
        let buttonColor = Color.white.opacity(0.18)
        
        VStack(alignment: .leading, spacing: 4) {
            // 1. 앱 로고
            HStack(alignment: .center, spacing: 8) {
                Image("AppLogo")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 32, height: 32)
                Text("이번달 지출")
                    .font(.system(size: 12, weight: .bold, design: .rounded))
                    .foregroundColor(.white)
            }
            .padding(.leading, 4)
            
            // 2. 금액
            Text(entry.content)
                .font(.system(size: 22, weight: .bold, design: .rounded))
                .foregroundColor(.white)
                .frame(maxWidth: .infinity, alignment: .center)
            
            // 3. 버튼
            HStack(spacing: 12) {
                Link(destination: URL(string: "wooribudget://addincome")!) {
                    HStack {
                        Text("💰")
                        Text("수입 추가")
                            .font(.system(size: 18, design: .rounded))
                    }
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity, minHeight: 44)
                    .background(buttonColor)
                    .cornerRadius(12)
                }
                Link(destination: URL(string: "wooribudget://addexpense")!) {
                    HStack {
                        Text("🧾")
                        Text("지출 추가")
                            .font(.system(size: 18, design: .rounded))
                    }
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity, minHeight: 44)
                    .background(buttonColor)
                    .cornerRadius(12)
                }
            }
            .padding(.top, 8)
        }
        .padding(.vertical, 6)
        .cornerRadius(24)
    }
}

 

[코드설명]

HStack(spacing: 12) {
    Link(destination: URL(string: "wooribudget://addincome")!) {
        // 수입 추가 버튼
    }
    Link(destination: URL(string: "wooribudget://addexpense")!) {
        // 지출 추가 버튼
    }
}
.padding(.top, 8)
  • 각 버튼 클릭 시, wooribudget://addincome 또는 wooribudget://addexpense 딥링크를 호출해 Flutter 앱을 엽니다.
  • 링크 사용을 위해 Info.plist에 CFBundleURLTypes - CFBundleURLSchemes에 wooribudget을 추가합니다.
<key>CFBundleURLTypes</key>
	<array>
		<dict>
			<key>CFBundleTypeRole</key>
			<string>Editor</string>
			<key>CFBundleURLSchemes</key>
			<array>
                <string>wooribudget</string>
			</array>
		</dict>
	</array>

 

iOS 위젯 확장의 메인 진입점으로, 실제로 홈 화면이나 위젯 갤러리에서 보여질 위젯의 구성과 메타데이터를 정의합니다.

기본적으로 위젯은 3가지 종류의 모양이 존재하는데, 가로로 긴 버전만 제공하기 위해 supportedFamiliessystemMedium을 추가했습니다.

 

struct WooriWidget: Widget {
    let kind: String = "WooriWidget"
    let bgColor = Color(red: 95/255, green: 148/255, blue: 247/255)

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                WooriWidgetEntryView(entry: entry)
                        .containerBackground(bgColor, for: .widget)
            } else {
                WooriWidgetEntryView(entry: entry)
                        .padding()
                        .background(bgColor)
            }
        }
        .configurationDisplayName("우리")  // 미리보기에 표시되는 문구
        .description("우리가 쓰려고 만든 공유가계부")
        .supportedFamilies([.systemMedium]) // 가로로 긴 버전만 제공
    }
}

 

[코드설명]

let kind: String = "WooriWidget"
  • 위젯의 고유 식별자입니다.
  • Flutter에서 업데이트를 요청할 때 사용하는 androidName, iOSName에 해당합니다.

 

 

2. 앱 내부 데이터 연동하기

딥링크 사용을 위해 uni_link를 업데이트합니다.

flutter pub add uni_links

 

Flutter에서 위젯으로 연결하기 위한 키를 설정합니다.

groupId는 Widget 추가시 입력한 App Groups의 이름입니다.

const groupId = "group.woori.homeWidget";
const widgetName = "WooriWidget";


앱 진입시 지출액을 위젯으로 보여주기 위해 main.dart에 아래와 같이 함수를 추가했습니다.

 

void setHomeWidgetAmount(total) async {
  await HomeWidget.setAppGroupId(groupId);
  await HomeWidget.saveWidgetData<String>(
      'WooriWidgetKey',
      '${total >= 0 ? '+' : '-'}${LanguageUtil.money(
        language: language,
        money: FormatUtil.formatCurrency(total),
      )}');

  await HomeWidget.updateWidget(
      androidName: widgetName, iOSName: widgetName);
}

setHomeWidgetAmount(total);

 

[코드설명]

  await HomeWidget.setAppGroupId(groupId);
  • groupId는 앱과 위젯 확장 모두 동일한 Group ID를 써야 합니다.
  • iOS에서 앱과 위젯 간 데이터를 공유하기 위해 App Group을 설정합니다.
  • Android에서는 내부적으로 무시됩니다.
await HomeWidget.saveWidgetData<String>(
      'WooriWidgetKey',
      '${total >= 0 ? '+' : '-'}${LanguageUtil.money(
        language: language,
        money: FormatUtil.formatCurrency(total),
      )}');
  • 이 값은 Swift 위젯에서 UserDefaults(suiteName:)로 읽어와 entry.content로 표시됩니다.(1. 참고)

마지막으로 수입/지출 버튼 클릭시 호출되는 딥링크 처리를 위해 메인 화면에 코드를 수정합니다.

_handleInitailUri() 함수는 앱이 최초 실행될 때 딥링크 URI를 받아 처리합니다.

StorageService().getInitialUriProcess()를 체크하는 이유는 딥링크 중복 호출을 방지하기 위해서였습니다.(앱 종료 상태에서 딥링크를 통해 화면이 이동되고, 메인 화면으로 왔을 때 다시 딥링크 URI로 이동되는 현상)

체크하는 방법은 SharedPreferences를 통해 호출 플래그값 true/false로 관리했습니다.

StreamSubscription? _sub;

  @override
  void initState() {
    super.initState();

    // 앱 실행 시 최초 딥링크 처리
    _handleInitialUri();

    // 위젯 수입,지출 버튼 클릭 리스너
    _sub = uriLinkStream.listen((Uri? uri) {
      if (uri != null) {
        _handleUri(uri);
      }
    }, onError: (err) {});

    WidgetsBinding.instance.addPostFrameCallback((_) async {
      StorageService().saveInitialUri(false);
    });
  }

  Future<void> _handleInitialUri() async {
    final isHandled = await StorageService().getInitialUriProcess();
    if (isHandled) return;

    try {
      final uri = await getInitialUri();
      if (uri != null) {
        _handleUri(uri);
        StorageService().saveInitialUri(true);
      }
    } catch (e) {
      // 에러 핸들링
    }
  }

  Future<void> _handleUri(Uri uri) async {
    String type = uri.host; //addincome(수입), addexpense(지출)
    String date = FormatUtil.convertDateFormat(DateTime.now());
    Token tokenInfo = await StorageService().getTokenInfo();

    Map<String, String> queryParams = {
      'userNo': '${tokenInfo.userNo}',
      'date': date,
      'type': type
    };
    Uri pageUri = Uri(
      path: '/ledger/create/nstep1',
      queryParameters: queryParams,
    );

    context.go(pageUri.toString());
  }

 

[코드설명]

_sub = uriLinkStream.listen((Uri? uri) {
  if (uri != null) {
    _handleUri(uri);
  }
}, onError: (err) {});
  • 앱이 이미 실행 중일 때 위젯에서 wooribudget://addincome 같은 딥링크를 보내면 여기서 처리됩니다.

테스트 결과를 보겠습니다.

[시뮬레이션 실행 결과]

 

참고자료
반응형
Contents

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.