본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

행의 동작은 탭으로 열어

~13 min · universal-ios, swiftui, interaction, consistency, menus

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"스와이프ㅠ.ㅠ 최악의 UI라고 생각해 아빠는. 메일에서도 이거 너무 싫어. 그냥 누르면 수정/삭제 나오면 그만인걸 왜 swipe를 해야 하는데?"

아무도 못 찾은 기능

여행 일지는 담기 수정과 삭제를 iOS다운 제스처인 .swipeActions 뒤에 넣어서 내보냈어. 주인이 빌드를 설치해서 써보고는, 담기를 수정하거나 지울 방법이 아예 없냐고 물었어. 기능을 짚어주자 이번엔 iOS 앱은 보기 전용이냐고 물었고. 기능은 폰에 분명히 있었는데, 주인한텐 없는 거나 마찬가지였어. 스와이프는 겉으로 아무 티가 안 나. 행 위엔 밀 수 있다는 표시가 하나도 없어서, 찾으려면 이미 알고 있어야 해.

이 앱들을 전부 쓰는 사람은 한 명이야

주인의 두 번째 질문이 취향 문제를 가족 규칙으로 바꿔놨어. 여행 일지, 크럼 일지, 훈련 앱, 네이티브 Pippa는 각자 다른 세션이 만든 거고, 세션마다 그럴듯한 이유를 대며 자기 제스처를 고르게 돼. 하나는 스와이프, 하나는 길게 누르기, 하나는 탭. 한 앱 안에서 보면 어떤 선택이든 괜찮아 보여. 근데 그 앱을 전부 쓰는 한 사람한텐 앱마다 다시 배워야 할 제스처야. 그래서 여기선 상호작용 일관성이 정확성의 문제고, 판정은 짧아. 행의 동작은 탭으로 열고, 가족 앱 어디에도 스와이프 동작은 없어. 여기서 얻는 일반적인 교훈은 스와이프 얘기가 아니야. 이 앱들 중 둘 이상에 생길 상호작용 패턴은 두 번째 앱이 고르기 전에 가족 계약에 들어가야 해.

둘 다 만족시키는 구조

행 전체를 Menu의 라벨로 만들어서 어디를 눌러도 동작이 열리게 하고, 끝에는 흐린 줄임표 하나만 힌트로 둬. 힌트는 그거면 충분해. 제대로 동작하게 하려면 챙길 게 두 가지 있어. 행 안에 .textSelection(.enabled)를 단 Text가 있으면 메뉴를 열어야 할 탭을 삼켜버려. 그러니까 복사는 메뉴에 넣어. 데스크톱 클라이언트도 복사를 메뉴에 둬. 그리고 List 섹션 헤더는 .contextMenu 길게 누르기를 절대 안 받는데, 컨트롤한테 탭은 전달해. 코딩 엔진의 폰 앱은 프로젝트 헤더에 제거 동작을 넣다가 이걸 알게 됐고, 제스처를 하나로 통일하려고 양쪽 다 탭 메뉴로 옮겼어.

모든 앱에서 규칙이 지켜지게 하기

한 앱 코드에만 있는 규칙은 그 앱에서만 지켜져. 그래서 가족은 이 규칙을 양보할 수 없는 다른 규칙들과 함께 공유 iOS 배포 문서에 적어뒀어. 가족 Swift 소스 전체에서 .swipeActions를 찾는 검색은 비용은 적게 들면서 계속 정직하게 남는 검사야.

Code

탭으로 동작을 여는 행, 그리고 똑같이 하는 섹션 헤더·swift
import SwiftUI
import UIKit

struct Crumb: Identifiable, Hashable, Sendable {
    let id: String
    var text: String
    var place: String?
}

/// The whole row is the menu's label: a tap anywhere opens Edit, Copy and Delete.
struct CrumbRow: View {
    let crumb: Crumb
    let edit: () -> Void
    let delete: () -> Void

    var body: some View {
        Menu {
            Button("Edit", systemImage: "pencil", action: edit)
            Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = crumb.text }
            Button("Delete", systemImage: "trash", role: .destructive, action: delete)
        } label: {
            HStack(alignment: .firstTextBaseline) {
                VStack(alignment: .leading, spacing: 2) {
                    Text(crumb.text)                       // no .textSelection: it would swallow the tap
                        .foregroundStyle(.primary)
                        .multilineTextAlignment(.leading)
                    if let place = crumb.place {
                        Text(place).font(.caption).foregroundStyle(.secondary)
                    }
                }
                Spacer(minLength: 8)
                Image(systemName: "ellipsis").foregroundStyle(.tertiary)   // the only hint needed
            }
            .contentShape(Rectangle())
        }
    }
}

struct JourneyList: View {
    let journey: String
    let crumbs: [Crumb]

    var body: some View {
        List {
            Section {
                ForEach(crumbs) { crumb in
                    CrumbRow(crumb: crumb, edit: {}, delete: {})
                }
            } header: {
                // A section header ignores .contextMenu long-presses; a tap Menu works there too.
                Menu {
                    Button("Rename Journey", systemImage: "pencil") {}
                } label: {
                    Label(journey, systemImage: "chevron.down")
                }
            }
        }
    }
}
모든 앱에서 판정이 지켜지는지 확인하는 훑기·bash
# A family-wide check is cheap, and it is the only kind that stays true across apps.
# Pass every app's Swift source folder. rg exits 0 on a match, 1 on none, and 2 on an error
# such as a folder that does not exist, so only 1 counts as clean.
(( $# > 0 )) || { echo "usage: sweep.sh <app source folder>..."; exit 2; }
for app in "$@"; do
  rg -n '\.swipeActions' --glob '*.swift' "$app"
  case $? in
    0) echo "HARD_FAIL swipe actions found in $app"; exit 1 ;;
    1) ;;
    *) echo "HARD_FAIL could not search $app"; exit 1 ;;
  esac
  rg -n '\.textSelection\(\.enabled\)' --glob '*Row*.swift' "$app" \
    && echo "check: a selectable Text inside a tap row swallows the tap ($app)"
done
echo "rows open on a tap in $# app(s)"

External links

Exercise

SparkMobile의 스와이프 동작을 전부 CrumbRow로 바꾸고, 섹션 헤더엔 탭 메뉴를 줘. 기기나 시뮬레이터에서 행 어디를 눌러도 메뉴가 열리는지 확인한 다음, 행의 글자에 .textSelection(.enabled)를 붙이고 뭐가 달라지는지 적어. 마지막으로 테스트 스크립트에 훑기를 넣어서 나중에 .swipeActions가 생기면 빌드가 실패하게 하고, 모든 앱의 Swift 소스 폴더를 넘겨서 돌려.
Hint
.contentShape(Rectangle())는 글자와 줄임표 사이 빈 공간도 탭 대상으로 만들어. rg는 찾으면 0, 못 찾으면 1, 옮겨진 폴더처럼 검색 자체를 못 하면 2로 끝나. 깨끗한 건 1뿐이라서, 경로를 잘못 적으면 통과가 아니라 실패가 돼.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.