ChromeのColorPickerを実装した

TODO: gif, refactor, github

         // https://gist.github.com/mjackson/5311256
        class Picker {
            constructor() {
                this.view = {
                    canvas: document.getElementById('canvas'),
                    colorPanel: document.getElementById('colorPanel'),
                    dummyPalette: document.getElementById('dummyPalette'),
                    paletteThumb: document.getElementById('paletteThumb'),
                    dummySlider: document.getElementById('dummySlider'),
                    sliderThumb: document.getElementById('sliderThumb'),
                    hexInput: document.getElementById('hex-input'),
                }
                
                this.colorUtil = {
                    rgbToHsv: (r, g, b) => {
                        r /= 255, g /= 255, b /= 255;
                        let max = Math.max(r, g, b), min = Math.min(r, g, b);
                        let h, s, v = max;
                        let d = max - min;
                        s = max == 0 ? 0 : d / max;
                        if (max === min) {
                            h = 0; // achromatic
                        } else {
                            switch (max) {
                                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                                case g: h = (b - r) / d + 2; break;
                                case b: h = (r - g) / d + 4; break;
                            }
                            h /= 6;
                        }
                        return [ h, s, v ];
                    },
                    hsvToRgb: (h, s, v) => {
                        let r, g, b;   
                        let i = Math.floor(h * 6);
                        let f = h * 6 - i;
                        let p = v * (1 - s);
                        let q = v * (1 - f * s);
                        let t = v * (1 - (1 - f) * s);
                        
                        switch (i % 6) {
                            case 0: r = v, g = t, b = p; break;
                            case 1: r = q, g = v, b = p; break;
                            case 2: r = p, g = v, b = t; break;
                            case 3: r = p, g = q, b = v; break;
                            case 4: r = t, g = p, b = v; break;
                            case 5: r = v, g = p, b = q; break;
                            default: break;
                        }
                        
                        return { r: r * 255, g: g * 255, b: b * 255 };
                    },
                    hexToRgb: (hex) => {
                        if (hex.length !== 6) { return }
                        const rgb = [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)]
                        .map(str => parseInt(str, 16 ))
                        if (rgb.includes(NaN)) {
                            return
                        }
                        return rgb
                    },
                }
                
                this.state = new Proxy({hue: 0, brightness: 1, saturation: 1}, {
                    set: (obj, prop, value) => {
                        if (prop === 'hue') {
                            this.didSetHue(value);
                            this.updateView();
                        }
                        obj[prop] = value;
                        if (prop === 'brightness') {
                            this.updateView()
                        }
                        return true;
                    }
                }) 
                
                this.handleSlider();
                this.handleColorPalette();
                this.handleHexInput();
                this.didSetHue(0)
                this.updateView()
            }
            
            
            drawGradient(fillColor) {
                const { canvas } = this.view;
                const context = canvas.getContext('2d');
                const rect = { x: 0, y: 0, width: canvas.clientWidth, height: canvas.clientHeight };
                
                const whiteGradation = context.createLinearGradient(0, 0, rect.width, 0);
                whiteGradation.addColorStop(0, 'rgba(255, 255, 255 ,1)');
                whiteGradation.addColorStop(1, 'rgba(255, 255, 255, 0)');
                
                const blackGradation = context.createLinearGradient(0, 0, 0, rect.height);
                blackGradation.addColorStop(0, 'rgba(0, 0, 0, 0)');
                blackGradation.addColorStop(1, 'rgba(0, 0, 0, 1)');
                
                [fillColor, whiteGradation, blackGradation].forEach(fillColor => {
                    context.fillStyle = fillColor;
                    context.fillRect(rect.x, rect.y, rect.width, rect.height);
                })
            }
            
            handleSlider() {
                const { dummySlider, sliderThumb } = this.view;
                let isSliderDragging = false;
                dummySlider.addEventListener('mousemove', (e) => {
                    if (!isSliderDragging) { return }
                    sliderThumb.style.left = `${e.offsetX - sliderThumb.clientWidth / 2}px`;
                    this.state.hue = e.offsetX / e.toElement.clientWidth;
                })
                dummySlider.addEventListener('mousedown', (e) => isSliderDragging = true);
                dummySlider.addEventListener('mouseup', (e) => isSliderDragging = false);
                
                dummySlider.addEventListener('touchmove', (e) => {
                    e.preventDefault()
                    if (!isSliderDragging) { return }
                    sliderThumb.style.left = `${e.changedTouches[0].pageX - sliderThumb.clientWidth / 2}px`;
                    this.state.hue = e.changedTouches[0].pageX / e.toElement.clientWidth;
                })
                dummySlider.addEventListener('touchstart', (e) => isSliderDragging = true);
                dummySlider.addEventListener('touchend', (e) => isSliderDragging = false); 
            }
            
            handleColorPalette() {
                const { dummyPalette, paletteThumb } = this.view;
                let isPaletteDragging = false;
                dummyPalette.addEventListener('mousemove', (e) => {
                    console.log(0)
                    if (!isPaletteDragging) { return }   
                    paletteThumb.style.left = `${e.offsetX - paletteThumb.clientWidth / 2}px`;
                    paletteThumb.style.top = `${e.offsetY - paletteThumb.clientHeight / 2}px`;
                    this.state.saturation = e.offsetX / e.toElement.clientWidth;
                    this.state.brightness = 1 - e.offsetY / e.toElement.clientHeight;
                })
                
                dummyPalette.addEventListener('mousedown', () => isPaletteDragging = true) 
                dummyPalette.addEventListener('mouseup', () => isPaletteDragging = false);
            }
            
            handleHexInput() {
                const { hexInput } = this.view;
                hexInput.addEventListener('input', (e) => {
                    const rgb = this.colorUtil.hexToRgb(e.target.value.slice(1));
                    if(rgb) {
                        const hsv = this.colorUtil.rgbToHsv(rgb[0], rgb[1], rgb[2]);
                        this.state.saturation = hsv[1]
                        this.state.brightness = hsv[2]
                        this.state.hue = hsv[0]
                    }
                })
            }
            
            didSetHue() {
                const rgb = this.colorUtil.hsvToRgb(this.state.hue, 1, 1);
                const color = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)`;
                const { sliderThumb } = this.view;
                sliderThumb.style.backgroundColor = color;  
                this.drawGradient(color);
            }
            
            updateView() {
                const { colorPanel, paletteThumb, sliderThumb, dummyPalette, dummySlider, hexInput } = this.view;
                const rgb = this.colorUtil.hsvToRgb(this.state.hue, this.state.saturation, this.state.brightness);
                const rgbaColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)`;
                colorPanel.style.backgroundColor = rgbaColor;
                paletteThumb.style.backgroundColor = rgbaColor;
                
                // TODO: rgb to hex string
                hexInput.value = `#${Object.values(rgb).map(value => ("0" + parseInt(value).toString(16)).slice(-2)).join("")}`;
                
                // thumb point
                paletteThumb.style.left = `${dummyPalette.clientWidth * this.state.saturation - paletteThumb.clientWidth / 2}px`;
                paletteThumb.style.top = `${dummyPalette.clientHeight * (1 - this.state.brightness) - paletteThumb.clientHeight / 2}px`;
                sliderThumb.style.left = `${dummySlider.clientWidth * this.state.hue - sliderThumb.clientWidth / 2}px`;
                
                // delegate
                if(this.onColorChange) {
                    this.onColorChange(rgbaColor);
                }
            }
        }
        
        const picker = new Picker()
        picker.onColorChange = (color) => console.log(color)
<html>
<style>
    .hue-slider {
        position: absolute;
        /* width, centerY, to parent element */
        top: 50%;
        transform : translateY(-50%);
        width: 100%;
        height: 10px;
        background: linear-gradient(to right,
        hsl(0,100%,50%),
        hsl(60,100%,50%),
        hsl(120,100%,50%),
        hsl(180,100%,50%),
        hsl(240,100%,50%),
        hsl(300,100%,50%),
        hsl(360,100%,50%)
        );
        border-radius: 12px;
    }
    
    .thumb {
        position: absolute;
        top: 50%;
        left: -12px; 
        /*  sliderの値が0の時thumbの中央がsliderの左端の真上に来るようにsize / 2ずらす */
        transform: translateY(-50%);
        width: 24px;
        height: 24px;
        border-radius: 15px;
        border: 2px solid white;
        cursor: pointer;
    }
    
    .color-panel-thumb {
        position: absolute;
        top: -12;
        left: -12px; 
        width: 24px;
        height: 24px;
        border-radius: 15px;
        border: 2px solid white;
    }
    
    .hue-slider-container {
        position: relative;
        height: 50px;
        width: 90%;
        margin: auto;
        background-color: white;
    }
    
    .dummy-slider {
        position: relative;
        width: 100%;
        height: 100%;
        background-color: transparent;
    }
    
    .vstack {
        display: flex;
        flex-direction: column;
    }
    .hstack {
        display: flex;
        flex-direction: row;
        justify-content: space-between;
    }
    #color-panel {
        width: 100%;
        background-color: red;
        position: relative;
    }
    
    #color-panel > div {
        color: white;
        font-size: 30px;
        top: 50%;
        left: 50%;
        transform: translate(-50%,-50%);
        position:absolute;
        text-align: center;
    }
    
    .container {
        background-color: white;
        width: 800px;
        border: 1px solid gray;
    }
    
    .color-string-container {
        width: 95%;
        margin: auto;
        height: 50px;
        
    }
    
    .hex-string-container {
        width: 100%;
        position: relative;
    }
    
    .hex {
        left: 50%;
        transform: translate(-50%,-45%);
        position: absolute;
        width: 35px;
        background: #fff;
        font-size: 12px;
        text-align: center;
        color: #3c4043;
    }
    
    
    .border {
        border: 1px solid #dadce0;
        border-radius: 5px;
        height: 36px;
        margin: 5px auto 5px auto;
    }
    
    input {
        text-align: center;
        background-color: #fff;
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%,-50%);
        width: 78%;
        border: none;
        font-family: Roboto,Arial,sans-serif;
        font-size: 12px;
        letter-spacing: 0.15px;
        color: #3c4043;
        outline: none;
    }
    
    .color-palette {
        position: relative;
    }
    
    
    #dummyPalette {
        position: absolute;
        background-color: transparent;
        width: 100%;
        height: 100%;
        top: 0px;
        left: 0px;
    }
    
</style>
<body>
    <div class="vstack container">
        <div class="hstack">
            <div id="colorPanel"><div>color</div></div>
            <div class="color-palette">
                <div id="paletteThumb" class="color-panel-thumb"></div>
                <canvas id="canvas" width="600px" height="300px"></canvas>
                <div id="dummyPalette"></div>
            </div>
        </div>
        <div class="hue-slider-container">
            <div class="hue-slider"></div>
            <div id="sliderThumb" class="thumb"></div>
            <div id="dummySlider" class="dummy-slider"></div>
        </div>
        <div class="color-string-container">
            <div class="hex-string-container">
                <div class="hex">HEX</div>
                <input id="hex-input" value="#123456">
                <div class="border"></div>
            </div>
        </div>
    </div>
    
    <script> </script>
</body>
</html>

RxJSを使ってincremental searchする

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>RxJS Demo</title>
    <style>
        body { font-family: 'Arial'; background: white; text-align: center;}
        #container { width: 40%; min-width: 340px; display: inline-block;}
        .search-bar { padding: 10px; background-color: white; border-bottom: 1px solid lightgray; margin-bottom: 30px;}
        #textfield { width: 100%; height: 20px; font-size: 20px; border: 1px solid #DDDDDD; border-radius: 4px; color: #333333; padding: 5px;}
        ul { list-style-type: none; padding: 0px;  }
        li { background: white; padding: 30px 10px; margin-bottom: 10px; height: 50px; border-bottom: 1px solid lightgray; text-align: left;}
        a { color: blue; font-size: 20px; }
        p { color: #777777; padding-left: 5px; }
    </style>
</head>
<body>
    <div id="container">
        <h1>Serch GitHub Repository </h1>
        <div class="search-bar">
            <input id="textfield" placeholder="Find a repository..." type="text"></input>
        </div>
        <ul id="list"> </ul>
    </div>
<script src="https://unpkg.com/rxjs@6.5.2/bundles/rxjs.umd.min.js"></script>
<script src="./src/index.js"></script>
</body>
</html>
const { from, fromEvent } = rxjs;
const { map, distinctUntilChanged, throttleTime, switchMap } = rxjs.operators;

const searchRepository = (name) => {
    return fetch(`https://api.github.com/search/repositories?q=${name}`)
                  .then(response => response.json())
};

const AddRepositoryList = repository => {
    const li = document.createElement('li');
    const a = document.createElement('a');
    a.link = repository.url
    a.innerText = `${repository.owner.login}/${repository.name}`
    const p = document.createElement('p');
    p.innerText = `■ ${repository.name} ${repository.owner.login}`;
    li.appendChild(a);
    li.appendChild(p);
    document.getElementById('list').appendChild(li);
};

fromEvent(document.getElementById("textfield"), 'input')
   .pipe(
       map(e => e.target.value),
       throttleTime(300),
       distinctUntilChanged(),
       switchMap(repo => from(searchRepository(repo)))
   )
  .subscribe(response => {
      const repositories = response.items;
      repositories.forEach(repository => {
          AddRepositoryList(repository);
      });
   });

Swiftでタグを入力するUIを作ったやつ

タグを入力するViewを作った

以前に下のようなタグを入力する画面を作成したのですが、これもアウトプットしないと勿体ない系だと思ったので簡単にまとめます。

f:id:churabou:20190904230345g:plain

類似するUIを持つ具体的なアプリがすぐには思いつかないのですが、日記アプリやメモアプリなどタグを入力するUIは存在すると思います。 このような他のアプリで使えそうなコンポネントを公開する際は、どこまでカスタマイズ可能にし、どのようなインターフェースで公開するのか決めるのが凄く難しいです。 それでも悩んで公開しないよりは妥協して公開したほうがいいと思うのでまとめます。

使い方

普通に初期化して使えます。

let tagField = TagFieldView()

引数はこんな感じです。

    init(
        placeholderText: String = "tags",
        defaultTags: [String] = [],
        tagViewStyle: TagView.Style = .init()
        layout: TagLayoutView.Layout = .init(margin: Margin(x: 8, y: 4)),
        validator: TagValidator = DefaultTagValidator()
     )

placeholderTextと初期値のタグ(編集画面で使う場合は既にタグの入力がある)とタグの見た目とレイアウトオブジェクトとValidatorです。 Validatorは入力されたタグの検証をするためのものです。Defaultクラスはタグが重複しないように実装しています。

struct DefaultTagValidator: TagValidator {
    func validate(tag: String, tags: [String]) -> Bool {
        return !tags.contains(tag) && !tag.isEmpty
    }
}

例えば禁止ワードを排除したりDB内に存在しないものみの入力を受け付けるなどが想定できたので外出しにしました。 また編集画面など初期値でタグを入れる場合にも同様のvalidationが走ります(初期値とし重複したタグを入れられることに実装中に気付いた)

実装方針

Styleを外から決めて、UIイベントをクロージャーで公開するTagViewクラスやタグを並べるだけのTagLayoutViewクラスなど 軽めのクラスを作成し、それを親クラスのTagFeildが管理する実装にしました。 結果的にテキストの検証の部分や並べ替えの処理などがしっかり分けられていて良かったと思います。 またレイアウト用のクラスがLayoutオブジェクトを使って描画するようにしているため、タグの並べ方を例えば中央寄せにするなどといった拡張もしやすくなりました。(Layoutクラスにalignmentプロパティーを追加してLayoutクラスでそれを考慮して並べ替えるように追加する)

UI/UIX??

  • Deleteキーを押すと一回目で直前の入力タグがハイライトされ二回目で削除されます。すでに選択されたタグがある場合はそれが削除されます。
  • 選択時にはアニメーションと透明度を変えて分かるようにしてます。

感想

Textfieldの見た目、タグが選択された時の見た目、削除ボタンの見た目などはハードコードしています。 見た目のカスタマイズ性は中途半端に妥協しました。というか別にライブラリを作ったわけではない。

個人で使う分に作るのは簡単だったけど、公開するために色々考えるのはかなり時間がかかってしんどかったです。

SwiftでTooltipを書く

SwiftでTooltipを書いた

こうゆうツールチップを書きました。

f:id:churabou:20190831151316p:plain

こんな感じに書けます。 本当はもう少し描画周りのリファクタしたりカスタマズ性を持たせたりしたいが、そもそも自分が使わないのでこの辺でアウトプットします。

    private let toolTip = TooltipView(option: Option(
        fillColor: UIColor(0xf59542)
        attributeText: NSAttributedString(
            string: "Top: #f59542",
            attributes: [
                .foregroundColor: UIColor.white,
                .font: UIFont.systemFont(ofSize: 14),
            ]
        ),
        padding: UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4),
        arrowPosition: .bottom(normalizedPointX: 0.4)
    ))

f:id:churabou:20190831150549p:plain

ソース

gist.github.com

nodebrewのメモ

brew install nodebrew
nodebrew -v 
churabou@churabou ~> nodebrew install v8.9.4
Fetching: https://nodejs.org/dist/v8.9.4/node-v8.9.4-darwin-x64.tar.gz
Warning: Failed to create the file
Warning: /Users/churabou/.nodebrew/src/v8.9.4/node-v8.9.4-darwin-x64.tar.gz:
Warning: No such file or directory

srcを作る。

 mkdir -p ~/.nodebrew/src
nodebrew use v8.9.4

簡単なGridLayoutを実装するためのLayoutクラス

はじめに

iOS開発をはじめて2年が経ち自分なりにUIKitでいろんなUIを作ってきました。そろそろアウトプットとしてgoogle検索で上位でてくる古い情報を上書きしようとと思っていた矢先、SwiftUIがでてきてしまい一気にモチベーションが下がってしまいました。ただなんかもったいなく感じたため1日10分程度をアウトプットの時間に割いていこうとおもいます。SwiftUIはあと1年遅くてもよかったかなぁ。

本題

UIKitでGridレイアウトを実装しようとするとUICollectionViewクラスを利用するのが一般的だと思います。 ただご存知の通りUICollectionViewを使う場合、UICollectionViewLayout, UICollectionViewDelegate, UICollectionViewDataSource、などのUICollectionViewのメソッドをいちいち呼ばなきゃいけないので結構面倒くさいです。 実際、昨年ぐらい(もっと前から?)宣言的に書くというのが、じわじわと広がってきて、今年になってSwiftUIもでてきてだいぶ宣言的にかけるようになりました。

CollectionViewはセルを再利用してくれるので、たくさんのデータを表示する際には使うのですが、場合によってはセルを再利用しなくてもいい場合があります。

そんなときにCollectionViewを使わずsubViewを並べてくれるレイアウトクラスをちょこっと作ってやるといいと思います。

再利用しないGridViewの例

使用例1

        gridView.backgroundColor = .white
        gridView.insets = .zero
        gridView.borderWidth = 5
        gridView.gridSize = 4
        
        let size = gridView.gridSize * gridView.gridSize
        let colors = (0..<size).map { UIColor(hue: CGFloat($0) / CGFloat(size), saturation: 0.85, brightness: 0.9, alpha: 1) }
        colors.forEach {
            let view = UIView()
            view.backgroundColor = $0
            gridView.addSubview(view)
        }
        gridView.layout.center(0).size(300) // オートレイアウトのExtension

カラーパレットとか

カレンダーとか

パスコードの数字入力画面とか

こうゆう画面をCollectionViewで実装しようとすると少し大袈裟というかコード量が増えちゃいますよね。

実装

final class GridLayoutView: UIView {
    var gridSize: Int = 4
    var borderWidth: CGFloat = 5
    var insets: UIEdgeInsets = .zero
    
    override func layoutSubviews() {
        super.layoutSubviews()

        let margin = borderWidth

        let width = (bounds.width - CGFloat(gridSize - 1) * margin - insets.left - insets.right) / CGFloat(gridSize)
        let height = (bounds.height - CGFloat(gridSize - 1) * margin - insets.top - insets.bottom) / CGFloat(gridSize)
        
        let startX: CGFloat = insets.left
        let startY: CGFloat = insets.top

        var x = startX
        var y = startY
        
        subviews.enumerated().forEach { index, view in
            view.frame.origin = CGPoint(x: x, y: y)
            view.frame.size = CGSize(width: width, height: height)
            
            x += width + margin
            if index % gridSize == gridSize - 1 {
                x = startX
                y += height + margin
            }
        }
    }
}

Tips

  • 中央のマージンはborderWidthで指定します。
  • 外側のマージンはinsetsで指定します。
  • ボーダーカラーはbackgroundColorを指定すればかわります。
  • subViewを自動で並べてくれるのでaddSubviewするだけです。

補足

等間隔にViewを配置するにはUIStackViewをよく使いますが、今回は使っていません。 内部でAutoLayoutを使用しているのと、純粋に座標計算したほうがコード量が少ないからです。

Storyboard派の皆さんはUIStackViewを使えば簡単にできるじゃないかと思うかもしれませんが 個人開発する上では圧倒的に時短かつ再利用可能です(コンポネントとして)

まとめ

ちょっとしたGridLayoutを実現するためのレイアウトクラスを作って使ってるよというはなし。 insert, delete, drag, reuseなどUICollectionViewのほうが優れている点が多い。

CoreAnimationのサンプル

雑にまとめます。 用語が一部正しくない場合がございます。

Pathに沿ってアニメーション

CAKeyframeAnimationにpositionで指定して、pathを指定してあげると パスに沿ってアニメーションします。

f:id:churabou:20190713215033g:plain

        let anim = CAKeyframeAnimation(keyPath: "position")
        anim.duration = 2
        anim.isRemovedOnCompletion = false
        anim.fillMode = .forwards
        anim.path = path.cgPath
        circleLayer.add(anim, forKey: nil)

https://gist.github.com/churabou/bcc3218df36018d345364eb5c18e537a#file-pathanimationview-swift

円形のチャートアニメーション

strokeEndを指定してあげるとPathのアニメーションをすることができます。

    let anim = CABasicAnimation(keyPath: "strokeEnd")
    anim.fromValue = 0
    anim.toValue = 1
    anim.duration = 1
    anim.isRemovedOnCompletion = false
    anim.fillMode = .forwards
    shapeLayer.add(anim, forKey: "key")

addArcメソッドで円を書く、そのパスをアニメーションさせます。 一つ下の階層に薄暗い背景のやつを表示することで円グラフのアニメーションができます。

     let path = UIBezierPath(
                    arcCenter: center,
                    radius: 100,
                    startAngle: -.pi / 2,
                    endAngle: .pi * 3 / 2,
                    clockwise: true
                )

f:id:churabou:20190713215026g:plain

https://gist.github.com/churabou/bcc3218df36018d345364eb5c18e537a

折れ線グラフの

点を決めてあげれば折れ線グラフのアニメーション

    let path = UIBezierPath()
    (0...10).forEach { i in 
        let point = CGPoint(x: CGFloat.random(in 0...1), y: CGFloat.random(in 0...1))
        i == 0 ? path.move(to: point) : path.addLine(to: point)
    }

f:id:churabou:20190713215030g:plain

 マスクアニメーション

CALayerをCAShapeLayerでmaskし、そのmaskのアニメーションをすることで、すでに描画してあるものを少しずつアニメーションしながら表示しています。

f:id:churabou:20190713215038g:plain

参考

チャートの色は適当に取ってきました。