より良いデザインにするために大切にしたいと思っていること

こんにちは。デザイナーの遠藤です。 私は今クックパッドiOS/Androidアプリのデザインを担当しています。

みなさんは、既存の機能を別のプラットフォームに追加する際に、あまり考えずにそのまま追加してしまい、後で後悔したことはないでしょうか?今回は、web版にある機能「料理の基本」をクックパッドアプリへ追加した時のことを交えて、より良いデザインにするために大切にしたいと思っていることをご紹介します。

料理の基本について

レシピをみて料理を作っている際に、「あれ、半月切りって何だっけ?」「水にさらすってどれだけやればいいんだろう」のような、基本的なことさえわからず手が止まってしまったことはありませんか?クックパッドでは、そんな方のために料理の基本を提供しています。今回私は、この機能をiOS/Androidアプリのレシピページに追加する際、デザインを担当しました。

この機能の使い方は、レシピ中の手順欄にある言葉から利用できます。 例えば、スマートフォンwebの場合、レシピ中にところどころ薄く下線が引いてあります。 その下線の部分をクリックすると、詳しく内容が見れるようになっています。 (↓下記の例では、下線「みじん切り」をクリックした場合)

デザイン検討のプロセス

上記のスマートフォンwebの機能をアプリに追加する際に、下記のようにデザインを検討しました。

(1)現状把握(ユーザーのシーンを理解、今まで出来ていないことの洗い出し)

PCやスマートフォンweb版では、料理の基本のキーワードをタップした際、レシピページから料理の基本ページへ遷移します。ユーザーは料理中にレシピを見ていることが多いため、料理中に画面の行き来が発生することで、本来の目的から少し脱線し料理の進行を妨げてしまっているのではないか、ということを課題にしていました。 それを踏まえて、アプリではなるべく本来の目的の「料理をする」ということを妨げずに、知りたい情報を簡単に知れることを重視することになりました。

(2)そのシーンでは、「ユーザーはどんな情報が必要で、何をしたいのか?」アイデアを考える

料理中にわからない言葉に直面して困ってしまったというシーンでは、どういった情報が必要で、何ができると嬉しいのか?というアイデアを出しました。以下は、そのアイデアの一部です。

  • 3Dtouchの機能を使えばすぐに内容が知れて嬉しいのではないか?
  • 画面遷移して見に行くのは料理を妨げることにならないか?
  • 「料理の基本」を全文見せる必要はないのでは?

こういったアイデアをプロジェクトの担当者同士で意見し合い、ブラッシュアップしていきます。 また、アイデアだけでは実際に操作感がわからないため、すぐにUIを具体化していきます。(3)

(3)アイデアをプロトタイプしてデザインの方向性を決める

アイデアが浮かんだ段階で、どんどんプロトタイプを作っていきます。 また、今回iOSとAndroid両方での実装が必要だったため、両方でどういったデザインが必要かも都度検討していきます。 以下、プロトタイプの一例です。

プロトタイプを作っていくとたくさんの気づきがあり、方針がとても固めやすいです。 例えば、上記の一番左のプロトタイプでは、モーダルを表示している時に背景を暗くしてモーダルを目立たせていましたが、「暗くすると、本来の料理をすることを妨げることになるのでは?」という意見のもと、背景を暗くするのをやめました。

(4)デザインが決定したら、周りのデザイナーやエンジニアにレビューしてもらう

プロトタイプである程度方針を固めたら、次に細部のデザインをつめ、決定したら周りのデザイナーやエンジニアに触ってもらいレビューをもらいます。 ここでは、

  • 他の機能の挙動との齟齬がないか
  • 情報が適切か、見せ方が適切か
  • 実装が現実的にできるものかどうか

の観点でアドバイスをもらいます。

決定したデザイン

プロトタイプ作成を重ね、周りの人にレビューしてもらった結果、このようになりました。

iOS / Android

リリース後の評判はどうか?

リリース後、一部のブログなどで「嬉しいアップデートだった」というご意見をいただいているのを発見し、とても嬉しく感じています。とはいえ、まだまだ課題もたくさんありますので、引き続き今後も改善していく予定です。

まとめ

今回、下記の3点を行ったことにより、ただの機能追加にとどまらず、より良いデザインは何か?ということを追求することが出来たと思っています。

★現状把握をする

既存の機能の追加であれば、現在できていることと、出来ていないことをきちんと把握することが大事です。もし何も考えずにそのまま追加した場合、ユーザーが現状で感じている疑問や不安を無視してしまう可能性があります。現状把握をして振り返ることで、デザインする際に大事にしたいことが浮き彫りになってくると思います。

★「ユーザーがしたいことに対してどうアプローチするのか」という方針をある程度定める

今回で言えば、「ユーザーが料理中にわからない言葉に出会った時に、その時していることをなるべく妨げずに、知りたい情報を簡単に知れるようにする」という方針があったので、それを軸にデザインがスムーズに出来たと思います。

★アイデアを出した段階でなるべく早くプロトタイプを作る

アイデアを出しているときに、頭のなかで想像するものでは議論が進まず、悩みのポイントもどんどんずれてきてしまいます。実際に触れるものになって気づく点や、想像と違ったものになってしまったときどうするかという方向転換も早い段階でできたのがとても良かったと思います。

これらは、今後もデザインをする際に大切にしたいと思っていることです。 既存の機能を他のプラットフォームへ追加する際にどう見せるべきか迷っている方は、ぜひ試してみてください。


クックパッドでは、より良いユーザー体験を届けていきたい!というデザイナーやエンジニアを募集しています。

クックパッド株式会社 採用情報

Core Text と遊んでみましょう

こんにちは、技術部モバイル基盤グループのヴァンサン(@vincentisambart)です。

この間、クックパッドの iOS アプリの開発で Core Text と色々遊んだので、今日は Core Text の話をしましょう。

課題は表示する文字の一部の裏に角丸長方形を表示することです。例えばクックパッド iOS アプリに表示されているリンクを長く押すと表示されている角丸長方形です。以下の画像は「落し蓋」に表示されるタッチフィードバックが見えます。区域を計算したら、その後タップ区域のためにも使えますしね。

f:id:vincentisambart:20160617074525p:plain

以下に説明するやり方はクックパッド iOS アプリのやり方を簡略化したものです。(クックパッド iOS アプリは実装時にまだ Swift を使い始めていなかったので Objective-C ですけども)

Swift Playground (Swift 2.2) で開発しましょう。コードは iOS 用ですが、少しいじれば OS X でも使えるはずです。スターティングポイントは下記の新しい画像に文字列を表示するコードです。

import UIKit
import CoreText

let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()

Playground上で画像の表示をインラインに表示できて便利です。コードが実行されたら画像が定義されている場所の右側に画像のサイズが表示されていて、マウスカーソルをその上に乗せたら右の一番端に表示されるボタンをクリックしたら画像がその下に表示されます。

f:id:vincentisambart:20160617074532p:plain

framesetter には色々な設定がありますが、それでも足りない人は CTTypesetterCreateWithAttributedStringCTTypesetterSuggestLineBreakCTTypesetterCreateLine で自分で行の列を作れます。

Core Foundation

開発を続けるには Core Text が返している Core Foundation の型(CFArrayCFRange)を扱わなければいけないけど、Swift だと少し扱いづらいですね。もっと扱いやすくしましょう。

まず、 CFRange はシンプルな struct なので、そんなに扱いづらいわけではないけど、便利関数が少ないですね。 NSRange はそういう便利機能が色々あるので、簡単に変換できるようにしましょう。

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

折角 NSRange をいじっているなら、あとで使えそうな便利機能を1個追加しておきましょう。

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

CFRange は簡単でしたが、 CFArray を Swift の配列に変換するのが少しややこしいです。一番自然なやり方でやってみましょう。

let lines = CTFrameGetLines(frame) as! [CTLine]

上記のコードを実行するとクラッシュします。実は Swift の配列に変換する前に NSArray に変換すると動きます。

let lines = (CTFrameGetLines(frame) as NSArray) as! [CTLine]

じゃあそれを関数にしてみましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    return (sourceArray as NSArray) as! [T]
}

上記のコードは動きますが、Xcode 7.3では、間違っている警告「Cast from 'NSArray' to unrelated type '[T]' always fails」が出ます。そしてなぜか関数の中だと as NSArray がなくても動きます(警告まだ出ますけど)。

警告や謎キャストが好きじゃないので。代わりに CFArray の中身を新しい Swift 配列に入れるようにしましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

結局また乱暴なキャストが必要になりましたね…でも仕方ない気がします。

各文字がどこに表示されるのか

CTFrame には、表示されるグリフの位置が決まっています。ある座標がどの文字に一番近いのか教えてくれる関数(CTLineGetStringIndexForPosition)、または文字列のあるインデックスのy座標を教えてくれる関数(CTLineGetOffsetForStringIndex)があります。けれど、文字列の各文字がとっているスペースを直接教えてくれる関数がありません。英語、日本語、だけを対象にするなら CTLineGetOffsetForStringIndex で簡単にできそうですが、もっと複雑な言語にも対応したければそんなに簡単にいきません。簡単じゃないとはいえ、そこまで複雑でもありません。

詳しい説明は公式Core Text紹介でご覧になれますが、軽く説明しましょう。CTFrame に何が入っているのかといいますと、単に CTLine(行) のリストです。「行」というのは "\n" で句切らているパラグラフではなく、表示の1行1行です。

そして各行が CTRun で作られています。"run" がフォント、フォントサイズ、方向、が同じなグリフのリストです。気をつけるべきなのは、元々指定されたフォントが同じでも、文字がフォントに存在していなければ、代わりに別のフォントが使われるので、別の"run"になるかもしれません。例えば、文字列が"Vincentと申します"とフォントがシステムフォント(San Francisco)の場合、2つのrunになります:

  • 「Vincent」のグリフが入ったrun (フォント:San Francisco)
  • 「と申します」のグリフが入ったrun (フォント:Hiragino Kaku)

求める長方形を探すには、各グリフを見て、そのグリフは選択されている文字から来ているのかを確認しましょう。

しかし、グリフがどの文字から来ているのか教えてくれる CTRunGetStringIndices という関数が少し使いづらいです。グリフごとに文字列の中の開始インデックスだけを返します。1つのグリフが複数の文字を使うことがあります。例えば合字(リガチャー)や結合文字列(combining character sequence)ですね。なので開始インデックスだけではなく、文字列の区域が必要ですね。

次のグリフの開始インデックスに1を引けば…と思った方いるかもしれませんが、残念ながらそんなにうまく行きません。アラビア語やヘブライ語だと 3, 2, 1, 0 になりますし、次のヒンディー語の文字列「हिन्दी」のCTRunGetStringIndicesの結果が:1, 0, 2, 5。並び順が予測できません。

では、各文字で開始インデックスに一番近いインデックスを探すコードを書きましょう。

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

実装

必要な物が揃ったので実装してみましょう。最初に書いたコードの上に上記のextensionや関数を入れて、 let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil) の下に以下のコードを入れましょう。(全部が入ったコードはこの記事の一番下にも入れておきます。)

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}

表示は CTFrameDrawの上に以下のコードを入れましょう。

UIColor.cyanColor().setFill()
for zone in zonesFound {
    let roundedRectanglePath = UIBezierPath(roundedRect: zone, cornerRadius: 4)
    roundedRectanglePath.fill()
}

f:id:vincentisambart:20160617074536p:plain

最後に

一番重要な処理はやってありますけど、改善の余地はありますね。

まず、制限が1つあります:roundedRectangleRanges が合字や結合文字列の真ん中で始まる/終わる場合、グリフ全体の後ろに角丸長方形を描くことになります。それが問題になるかどうかは使う場所次第ですね。

あと、一番上のスクリーンショットみたいに、複数行に渡る時は行末や行頭では角丸にしない方がいいかもしれません。クックパッド iOS アプリでやっていて、そこまで難しくないけど記事が既に十分長いです(笑)。

全コードまとめ

import UIKit
import CoreText

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}


let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}


// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    UIColor.cyanColor().setFill()
    for rect in rectsFound {
        let roundedRectanglePath = UIBezierPath(roundedRect: rect, cornerRadius: 4)
        roundedRectanglePath.fill()
    }

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()

開発コストを最小限にして施策を進める

投稿推進部・ディレクターの中山です。
普段ディレクターはエンジニアとペアを組んでサービス開発をすることが多いですが、エンジニアが別の開発に集中したい時は、ディレクターだけで施策を進めることもあります。エンジニアがいないと動くものができない…と言っていては何もできません。
既に多くのディレクターの方が試していることかもしれませんが、実装に入る前に紙レベルでモノを作ってテストしたり、一般に出回っているツールを活用してみたり…と方法は色々とあります。私がここ数ヶ月で実践してきたことを、おさらいも兼ねてご紹介したいと思います。

方法1:紙でイメージを膨らませる

サービスを考える際、いきなり実装に入ることはまず無いと思います。
当たり前の話かもしれませんが、スピーディにイメージを掴むには手書きが便利。何度も書いたり直したりしつつ頭の中のイメージを具体化し、周囲のスタッフに当ててみることがができます。 f:id:akoakon777:20160614182920p:plain

例えば上記の写真の例は、見やすく書きやすいレシピフォーマットについて考えて書いたもの。普通のノートやレシピ用に売られているカードなどもあり、これらに実際に書いてみたり、スマホサイズに切り取ってみたりして使用感を試します。
右は手書きではありませんが、自分のレシピをカード状に置き直して紙に印刷してみたもの。普通のA4の紙に印刷し余白を折って整えただけのものですが、手に取ってみることで「文字はもうちょっと減らしたいな」「写真サイズはこれくらいが良いな」などの実感を得ることができます。

方法2:一般に出回っているツールを活用

プロトタイプツール

コードが書けなくてもFlintoやProttなどのツールを使えば動きのあるプロトタイプを作れる良い時代です。手書きや紙のコンテンツでイメージが出来たらこれらのツールで動くものを作り、スマホの実機でユーザーテストをすることができます。

ここでちょっと気をつけたいのは、プロトタイプツールが便利なだけに、ついつい作り込み過ぎてしまうことです。細かいデザインや動きは実装段階でデザイナーやエンジニアにお任せすれば良いので、ここではあくまでもユーザーが画面を見た際に、どういう意図をもってどんな動きをしようとしているのか を確かめます。
ユーザーのどんな課題をどうやって解決するツールなのか
を明確にしておき、その意図のようにユーザーが動いているか(或いはどこで躓いているのか)を確認する目的で使います。

SNSを活用

自分でプロトタイプを作らずとも、既に人が集まっていて尚且つ投稿機能を備えた場を借りることもできます。
TwitterやInstagramのようなSNSは非常に便利。
例えば「写真+1行のキャプション」のようなひと纏まりのコンテンツが周囲の人の興味をひけるのか、を試したい場合。そのまま自分で「写真+1行のキャプション」を作成し、コンテンツとしてSNSに投下してみると、「いいね!」やコメントの数で手応えを掴むことができます。あくまでも参考程度ではありますが、ざっくりとした反響を掴むことで、プロダクトの方向性が正しいのかを確かめることが可能です。

サービスの作りそのものを参考にできるだけでなく、こうしたちょっとしたテストにも活用できるので、自社以外の人気のサービスを普段から自分で使い込んでおくことは非常に有益だと思います。

方法3:既存の社内ツールをうまく使う

新しい施策を実施する際、目的に合わせて新たな機能を開発したくなってしまいますが、エンジニアのリソースは限られています。新しく作る前に、既にある仕組みを活用できないか考えてみると良いでしょう。
例えば弊社の場合は、ユーザー向けの汎用的なアンケートツールや連絡先の取得ツールが既にありました。社内で専用のものを内製していない場合でも、一般的に使えるアンケートやメールのような仕組みは色々あるのではないでしょうか。 本当に開発が必要なのか、手持ちのツールを組み合わせて解決できる可能性をまずは考えてみると開発の手間を省けるというのはよくあります。

次に、ここまでにご紹介した手法を組み合わせて今年の春に実施した「母の日のフォトブック企画」の事例をご紹介します。

「母の日のフォトブック企画」

考えた企画は、
ユーザーさんが「母の日」というイベントを前に、お母さんの思い出の味をレシピにしてをクックパッドにのせる
→自分はいつでも料理を再現できて便利になり、お母さんには記念のフォトブックとともに感謝の気持ちを届けることができる
  というもの。

手作りの試作品でテスト

まず、本当にこの企画がユーザーの心に響くのか、をテストするため、私自身がクックパッドにのせている母親のレシピを紙に印刷し、手作りのフォトブックを作成。 f:id:akoakon777:20160614183020p:plain

写真は粗いし作りも雑でお恥ずかしいレベルですが、この段階でのクオリティは気にしない。とは言え、これでも手に取ってみるとなかなかの達成感があります。※1

次に、実際にこの手作りの試作品を遠方に住む自分の母親に予告なしで送りつけてみて、電話で感想をきいてみました。
いきなり送りつけられた母親はとても驚いていましたが、電話の向こうで涙ぐむほど喜んでいて、送ったこちら側はガッツポーズできるほどの達成感。同時に、伝わりきらなかった部分のヒアリングもでき、テストとしては十分な手応えを掴むことができました。

本番の企画を実施

上記のテストのフィードバックを踏まえ、企画を本格的に実施すべく動かし始めました。

企画を立てた当初は、ユーザーが自分のレシピを選んでサクッと応募できる仕組みを開発するつもりでいました。
しかし、スケジュール的にも開発リソース的にも無理がある。そこで既存の社内のツールを色々調べてみると、アンケートフォームと住所取得フォーム、確認のメールを直接やり取りできる仕組みなどがありました。
これらを組み合わせれば、なんとかできないこともなさそうです。
もちろん、既存のアンケートフォームの仕様に縛られるので、ユーザーが入力するテキスト量が多くなり、手間をかけさせてしまう部分もあります。
そこで、事故を防ぐために気をつけたのは以下の2点です。

  • ユーザーさんから新たに受け取る情報を最小限にする
  • 仕様をシンプルにしてユーザーさんの考えるコストを減らす

具体的には、レシピのフォーマット内に既に書かれている内容をそのままフォトブックに採用。これで応募時のテキスト入力の手間やミスが減ります。また、フォトブックの仕様は「写真入りレシピ5品」という1パターンのみに統一し、余計な選択するための思考コストをなくしました。
一方で、試作の段階では自分(娘)が母親のために作った世界で1冊のフォトブックである、という部分が伝わりきらなかったので、冒頭の1ページ目にお母さんへのオリジナルメッセージを入れる仕様に。ここで皆さんがお母さんへの思いを伝えられるようにしました。

このように既存のフォームを活用してなんとか応募の裏側の仕組みを整え、表側は1枚の告知ページだけを用意することで、実装のコストを最小限に抑えることができました。

スマートな応募フォームを用意できるに越したことはありませんが、最終的なゴールはユーザーが満足できるフォトブックを作成してお届けすること。今回の企画のように、スケジュール内でその目的を達成するため、多少の使い勝手が下がっても実現可能な方法を選択したほうが良い場合もあります。
結果的に大きな事故もなく、皆様に素敵なフォトブックを作成してお届けすることができました。※2 応募してくださった方からはメールやブログなどでの好意的な反響が通常の6倍ほどもあり、大変ご満足いただけたという印象です。

まとめ

何か施策を進めようとする時、まずはエンジニアのリソース確保…と考えてしまいがちですが、ディレクターだけでできることは色々あります。紙や既存のツール、SNSやリアルな人間関係などを駆使すればある程度のテストも可能。
エンジニアには本当に必要な開発に集中してもらえるよう、今後もこれらの手法を常に意識して取り組んでいきたいです。

※1 社内のプリンターでA4用紙に印刷したものをハサミで切ってビニール製のポケットブックに入れ、マスキングテープを貼って綴じただけのもの。
※2 本企画でユーザーの皆様にお届けしたものは専門業者さんに製本してもらった素敵なものです。ご安心ください。